Jungle Trail
A first-person walk down a winding jungle trail into overgrown stone ruins with a waterfall, built in Three.js with zero external art assets. Every texture, mesh and sound in the scene is generated procedurally in code. There are no image files, no models, no audio recordings and no material libraries: the leaf atlas, the bark, the ground, the stone, the character's skin and all sixty audio buffers are computed at load time.
!Nine seconds along the trail: the canopy corridor, the ruins, the falls
*Captured from the running build with tools/reel.mjs, one simulated frame at a
time. Nothing in it is composited and nothing in it is a cut-away.*
Walk it: https://starknightt.github.io/jungle-trail/
Read the brief it was built from: PROMPT.md. One page, unedited, and most of the story of how this exists at all.
It wants a desktop GPU. A phone is told so before the scene is built and can go in anyway; there is no reduced-quality mobile version, because the quality is the point of the thing.
Running it locally
There is no build step. The page is plain ES modules with an importmap, so it only needs a static file server.
git clone https://github.com/StarKnightt/jungle-trail.git
cd jungle-trail
npm run serve
Then open http://localhost:8099/. Any other static server works equally well;
opening index.html from the filesystem does not, because ES modules and workers
need a real origin.
npm install is only needed for the capture tools in tools/, which use
Playwright. The game itself does not need it.
!Trailhead under closed canopy
Controls
| Input | Action | |---|---| | Click | Lock the pointer | | Mouse | Look | | W A S D | Move | | Shift | Sprint | | Space | Jump | | 1 - 5 | Teleport along the trail | | F3 | Show or hide the debug overlay |
The teleport keys are 1 trailhead, 2 mid trail, 3 ruins approach, 4 temple clearing, 5 the falls. The debug overlay is hidden on load and stays out of the document until F3 puts it there; once it is up, its header collapses it to a summary bar and F3 takes it away again.
On a touch device there is no keyboard and no pointer lock, so there are two controls instead: drag anywhere to look, and hold the pad in the corner to walk. They exist nowhere else. On a desktop the document is a canvas and a script tag, and that is the whole of it.
What is in it
- ~12,000 lines of hand-written code across 43 files in
src/, comments and
- A 423.8 m trail across a 180 x 492 m world, from a 361 x 985 heightfield
- 100,799 individual plants across 16 species, all built from two primitives: a
- 536 individually eroded stone blocks. The ruin plan is computed before the
- A procedural character: 22 bones, 7,488 triangles, and a 512-square procedural
- 15 GPU texture bakes producing 29 images. The leaf atlas is 2048-square; the
- 17 distinct synthesized voices and 60 audio buffers, baked in a worker on the
Performance
9 to 10 ms per frame on an RTX 4060 at 1600x900 on the high tier. The two ends
of that are 8.5 ms facing the falls, where 234 scene draw calls cover the screen
in water and stone, and 11.0 ms in the canopy corridor, where 495 calls put 6.6
million triangles of leaf card in front of the camera. The game caps itself at
60 fps; there is no reason for a walking-pace scene to render at 300, and the
frame rate is therefore not the measurement -- the millisecond is.
Those numbers are higher than the ones this file used to quote, and the frame
did not get slower. glFinish in a page does not wait for the GPU: Chromium
runs WebGL over a command buffer into a separate process, and finish returns
once the queue has been handed over. The tools now synchronise on a one-pixel
readPixels, which cannot return before the frame exists. Under the old timer
the post-processing chain appeared to cost forty microseconds and ultra
rendered faster than medium.
Of that frame, the whole post stack -- volumetrics, occlusion, grade, bloom,
defocus and shutter -- is 1.9 to 2.0 ms at high. The grading chain is 0.22 ms of
it standing still and 0.28 ms while the camera moves: defocus 0.13, bloom 0.12,
shutter 0.11 when there is motion to integrate, and the grade, the vignette, the
aberration and the grain between 0.01 and 0.06 each, because they are arithmetic
inside a pass that has to run anyway. Those are means of three runs; the spread
between runs on a single figure is about 0.03, which is why the isolated pass
timings are quoted rather than a frame-level difference -- a quarter of a
millisecond does not show up reliably against a 9 ms frame that varies by one.
Every tier runs every effect. Defocus and the shutter were high and ultra
only in the first version, which meant half the quality ladder was shipping a
look nobody had reviewed; measured, six defocus taps cost 0.05 ms, and the tiers
now scale the sampling of each effect rather than its presence.
The pool's planar reflection is the one pass that is not free: it is a second
submission of the whole clearing and it costs about 1.4 ms of a falls-facing
frame. It is skipped entirely whenever the pool is off screen, which is most of
the walk, it runs at 36 per cent resolution, it reuses the shadow map the main
pass is about to use, and it refreshes on alternate frames. Off below the high
tier, where the water falls back to a graded analytic reflection.
Techniques
GPU texture baking. Every texture is a GLSL surf() function rendered into a
render target. Normal maps are derived by Sobel-sampling the same function rather
than being authored separately, so the normal can never disagree with the albedo
it belongs to.
Noise. Perlin FBM and ridged Perlin, Ashima simplex in 2D and 3D, periodic
domain-wrapped Perlin taking a vec2 period so a texture can tile at a different
rate on each axis, and Worley cellular noise.
Sky and lighting. Atmospheric scattering is baked to a cube and PMREM-prefiltered, which means the sky you look at and the image-based lighting on every surface are literally the same function evaluated twice, instead of a skybox plus a separately tuned ambient term that drifts away from it.
Canopy shadowing. The canopy is not in the shadow map. It is replaced by an analytic transmittance term, which is both cheaper and better behaved than trying to resolve a hundred thousand leaf cards in a depth buffer.
Volumetrics. A half-resolution dithered raymarch for the light shafts.
Post-processing. The scene is never tone mapped more than once, and the tone map is the last thing that happens rather than the first. Everything that adds or moves light -- in-scattered mist, lens glare, defocus, the shutter -- happens in linear HDR before the curve, because all of it is radiance. The grade sits on both sides of the curve: channel crosstalk and an ASC slope/offset/power on the linear side, where a film stock's response lives, and the toe, the midtone contrast and the split tone after the transfer function, where a print's densities live. Putting the print half in linear is a mistake worth naming: a toe lift of 0.014 becomes thirty-two code values there.
The circle of confusion is the thin-lens formula for a 35 mm lens at f/2.8 focused at 5.5 m, with the sensor size derived from the render's own field of view so the two agree. Motion blur has no velocity buffer behind it; it reconstructs screen velocity from the depth buffer and the previous frame's view-projection, which is exact for everything in this world that is not growing. The sky needs no special case in that reconstruction and gets none: it has no geometry, so the buffer holds the clear value there, the far plane unprojects, and under rotation the answer is exactly right. What had made the sky the sharpest thing in a fast pan was not the velocity but the weight -- far taps were rejected on depth alone, and every tap on a sky pixel is far. Rejection is now on parallax, which pure rotation puts at zero.
The numbers these were tuned to are all differences of controlled pairs, which is
to say two frames from one frozen world state with one term switched between
them. It matters more than it sounds: the predecessor of tools/fx.mjs set each
half of a pair up from scratch, so the two frames were two seconds of falling
water apart and differencing them measured the waterfall. Grain measures a
luminance sigma of 1.24 over a frame and 1.60 at the peak of its density curve,
against 0.88 in deep shadow and 0.82 in the shoulder. Bloom adds 1.17 code values
to the frame mean at the falls and 8.0 across the top of the curtain, and 0.00 --
to the last code value -- in a dark corner a third of a frame away, which is the
difference between veiling glare and haze. Defocus removes 42 to 52 per cent of
the laplacian energy of the near understory, at every tier. The shutter's blur
grows monotonically to at least 137 degrees a second, where a frame travels 43
pixels against a sampling limit of 56, and the sky smears with everything else.
Nothing on the walk clips. The largest single channel value in a frame is 241, at all seven stops on all four tiers, and the share at or above 245 is 0.000 per cent -- which is the standing requirement for the waterfall and is met with room to spare.
Audio. The DSP is pure functions: Float32Array in, Float32Array out, with
no Web Audio anywhere in the synthesis path. Only src/audio/engine.js touches
the Web Audio API. That separation is what lets the exact same code render the
soundscape to WAV files offline in Node, which is how it gets measured.
The ambient beds loop at 9.31, 10.69, 11.73, 12.07, 13.37, 13.93, 14.91 and 16.41 seconds. Those lengths are mutually incommensurate, so the soundscape has no common period and never audibly repeats.
Status
Honest version: this is not finished.
- In: terrain, vegetation, lighting, ruins, character, audio, water and
- Closed at 6/10 after four critic passes: water. A blind critic scored it
- Reviewed at 7/10: post-processing. A blind critic called it the best-built
low and medium instead of
being a measured no-op there, the bloom pyramid reaches far enough to be worth
having, and the grain is at an amplitude a soft frame can use.
- Open, and the largest thing left in the project: the middle distance.
a8842aa and in the comment above GRADE in
src/render/grade.js. It should be picked up in the vegetation and lighting
materials first, because a grade cannot manufacture channel separation the
render did not hand it.
The vegetation and lighting critics signed off at 5/10 and 6/10 respectively, and they signed off on diminishing returns rather than on perfection.
A note on dependencies
package.json has zero runtime npm dependencies, but this is not a page that
loads nothing over the network: Three.js r170 is fetched from a jsDelivr CDN at
runtime through an importmap in index.html. Three.js and nothing else.
The zero-asset claim is a separate one, and it is airtight. There is no
TextureLoader, GLTFLoader, RGBELoader, AudioLoader, fetch,
XMLHttpRequest, new Image or createImageBitmap anywhere in src/.
There is exactly one image element in the project and it is not in the scene:
the still on the mobile capability screen. Its source is a base64 data URI in
src/mobile/poster.js, a 29 kB frame of this renderer carried in the source
rather than fetched from media/, because a path into media/ would be the one
runtime asset load this page does not make. It is imported only by the gate,
which only a coarse pointer reaches, so a desktop never downloads it.
How it was built
The original brief this was built from is kept unedited in PROMPT.md.
Each system was built and then reviewed by a separate critic that saw only rendered screenshots and never the source. The critic scored photorealism against real jungle photography, and the system was iterated until it passed.
Reviewing renders rather than code caught real bugs that reading the source would not have:
- Tree trunks rendering black, from inverted quad winding.
- Dark outlines around every leaf, from a premultiplied-alpha bug in the texture
- Volumetric light shafts standing vertically at sunset, because canopy distance
Licence
MIT. See LICENSE.