Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 60 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ npm install
npm run dev # → http://localhost:5173
```

No API keys, no server, no account. It is a static page and three.js.
No API keys, no server, no account, and no libraries: it is a static page with
its own renderer, drawing with WebGPU or WebGL 2.

## Play

Expand Down Expand Up @@ -90,15 +91,15 @@ comes back on the beat.

The run is a plain object graph with no pixels in it — course, egg, lives,
score — and the renderer reads a snapshot of it every frame. Nothing in
`src/core/` imports three.js or touches the DOM, which is why a seed can be
`src/core/` touches the GPU or the DOM, which is why a seed can be
played out headlessly in a test and asserted on.

```
index.html Markup only — Vite's entry
src/
main.js The wiring, and nothing else
styles.css The HUD, and the CRT it pretends to be on
core/ The game. No three.js, no DOM, no randomness it did not seed
core/ The game. No GPU, no DOM, no randomness it did not seed
game.js Lives, score, pickups, and real seconds → fixed ticks
course.js The course, laid a pattern at a time, ahead of the egg
player.js Gravity, lanes, jump, coyote time, landings
Expand All @@ -107,7 +108,7 @@ src/
rng.js A seeded stream, so a seed is a course
motion.js The spring and the chase everything eases on
emitter.js
render/ three.js. Reads snapshots, owns no game state
render/ The Matrix, built out of gpu/. Reads snapshots, owns no game state
scene.js Renderer, camera, fog, backdrop, and the light for the egg
view.js Snapshot → scene graph, and the chase camera
rig.js Where that camera sits and what it looks at, as arithmetic
Expand All @@ -119,6 +120,20 @@ src/
rain.js The backdrop, painted on a canvas
materials.js Four shared line materials
theme.js One colour, and the two warnings
gpu/ The renderer. Knows nothing about eggs
renderer.js Picks WebGPU or WebGL 2, and draws a scene with either
frame.js What is visible, in what order, and every uniform byte
webgpu.js The WebGPU backend
webgl.js The WebGL 2 backend
graph.js Nodes, meshes, lines, lights, the camera
geometry.js Vertex data, and the spheres, cylinders and rings
material.js Lit, unlit and line materials, as plain data
texture.js A canvas and how to sample it
environment.js His studio, prefiltered into a ladder of blurs
dfg.js The table the specular highlight is read from
math.js Vectors and double-precision matrices
color.js sRGB in, linear light inside, sRGB out
shaders/ The GLSL and the WGSL, one of each
ui/
hud.js The readouts and the panel between runs
input.js Keys and swipes → one frame of intent
Expand Down Expand Up @@ -147,12 +162,52 @@ There is also an autopilot in `test/helpers/pilot.js`. It plays badly on
purpose — one frame of lookahead, no double jump — and the suite fails if it
cannot get a few hundred metres down a seed.

### It draws its own pixels

There is no 3D library in here. `src/gpu/` is a renderer written for this world
and nothing else: a scene graph, three shaders — lit, unlit, and the
backdrop — and two backends that draw them. Which one draws comes down to
antialiasing: WebGPU multisamples at 4× and no more, while Chrome gives a
WebGL canvas 8× on a desktop GPU, which is what the game was tuned under. So
where WebGL can sample more than 4× it draws, and elsewhere — most phones —
WebGPU does. Either falls back to the other if it will not start, and
`?renderer=webgl` or `?renderer=webgpu` in the address picks one by hand. The
shaders are in `src/gpu/shaders/`, as `.glsl` for WebGL and `.wgsl` for
WebGPU, and they are the same shaders twice: change a constant in one and
change it in the other.

The two backends cannot disagree about what to draw, because neither of them
decides. `frame.js` walks the scene once a frame, culls it, sorts it — solid
things first, see-through things far to near — and packs every uniform into
blocks of vec4s and mat4s that std140 and WGSL lay out byte for byte the same.
A backend only says *draw this*. `test/gpu.test.js` reads the GLSL and the
WGSL and fails if either has drifted from what `frame.js` packs.

Everything in the world but the egg is one-pixel lines added onto the black,
and that is what the GPU draws them as — native lines, additive, faded into
the black by the fog before they are added, so a slab at a hundred metres is
a slab at a tenth of the brightness rather than a slab behind a curtain. The
deck under them is a black fill pushed a hair back in depth, so a rung on its
top surface is never fighting it for a pixel. The rain is a canvas stretched
over the whole frame behind everything, at a third of its brightness.

The lighting is the lighting Marc was made under, kept term for term: GGX specular with
multiple-scattering compensation read off the same DFG table, Charlie sheen
and the clearcoat on Marc's shell, fog mixed in after the encode. The
environment the lit surfaces reflect is prefiltered into a cube-UV ladder of
seven blurs by GGX importance sampling — on the CPU, in a worker, because the
cube is sixteen texels a side — and the shaders pick a rung by roughness
through the same curve as before. Rendered side by side with the build that
used a library, frame for frame down a seeded run, the WebGL picture agrees
with it to within one level in 255, and the WebGPU one differs only in which
pixels along an antialiased edge get a sample.

| Script | |
|---|---|
| `npm run dev` | Vite |
| `npm run build` | Bundles to `dist/` |
| `npm run preview` | Serves the build |
| `npm test` | `node:test` over the core, the HUD, the page and the music |
| `npm test` | `node:test` over the core, the renderer, the HUD, the page and the music |
| `npm run lint` | ESLint |

CI runs the lint, the tests on Node 22.12 and 24, and a build that then has to
Expand Down
9 changes: 0 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@
"test": "node --test \"test/**/*.test.js\"",
"lint": "eslint ."
},
"dependencies": {
"three": "^0.185.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.9.1",
Expand Down
35 changes: 35 additions & 0 deletions src/gpu/color.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Colour, in the two encodings that matter. Every hex in the theme is sRGB —
* what a designer picked off a swatch — and every sum the shaders do is in
* linear light, so a colour is decoded once on the way in and encoded once
* on the way out, in the fragment shader, and nowhere in between.
*/

export function srgbToLinear(c) {
return c < 0.04045 ? c * 0.0773993808 : Math.pow(c * 0.9478672986 + 0.0521327014, 2.4);
}

export function linearToSrgb(c) {
return c < 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 0.41666) - 0.055;
}

/** A hex number or a `#rrggbb` string, as its three sRGB channels in 0–1. */
function channels(value) {
const hex = typeof value === 'string' ? parseInt(value.replace('#', ''), 16) : value;
return [((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255];
}

/** A theme colour, decoded to linear light — what a material or a light holds. */
export function linear(value, scale = 1) {
return channels(value).map((c) => srgbToLinear(c) * scale);
}

/**
* A theme colour as the screen wants it. Fog and the clear colour are mixed
* after the shader has encoded its output, so they have to arrive encoded
* too; the round trip through linear is kept so the numbers land exactly
* where a decoded-then-encoded colour would.
*/
export function display(value) {
return linear(value).map(linearToSrgb);
}
31 changes: 31 additions & 0 deletions src/gpu/dfg.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading