Keikoku Camp — a camping sim where every texture, mesh and sound is generated in code

A browser camping sim set in a Japanese river valley. The thing I want to show is
that there are no asset files at all — no images, no meshes, no audio. The repo
has zero binaries. Everything is built at load time.

Materials — one procedural texture library feeding MeshPhysicalMaterial.
Woven thread structure for the fabrics (warp/weft with per-thread tension jitter),
rolled-steel flow for the blackened firepit, radial grain for split logs. Normals
are Sobel-derived from the height field, not hand-authored. Wear is driven by real
curvature (length(fwidth(N)) / length(fwidth(worldPos)), thresholded in 1/m) so
fillets and rims wear but flat bodies don’t.

Terrain — heightfield with triplanar splat blending, hex-tiling (3-tap
barycentric, translation only so derivatives stay valid) to kill the repeat.

Fire — volumetric raymarch, colour from blackbody radiation rather than a
palette. It’s also the light source at night, with the flicker driving the
PointLight intensity.

Audio — Web Audio only, no samples. Fire crackle is a Poisson process with
per-event filter/decay variation. The river is four independently modulated noise
layers. Autumn insects only chirp after sunset (and no higurashi — those are a
summer cicada).

Three.js r185 + Rapier, ~130 source files, two runtime dependencies.
Playable (desktop only, WASD + mouse):


A few things that cost me time, in case they’re useful

onBeforeCompile hands you the shader with #include unexpanded. So
string-replacing the expanded chunk silently no-ops. My stochastic tiling was
dead for three review rounds before I hooked shaderSource and counted call
sites — the function was defined, never called. Pull the chunk out of
THREE.ShaderChunk, patch that, and replace the #include itself.

Half-res AO reading a full-res NEAREST depth buffer. The half-res pixel centre
lands exactly on a full-res texel boundary, so which side you get depends on float
rounding. Reconstructed normals tilted ~25° on a surface at grazing incidence,
which produced perfectly regular vertical black stripes on the tent. Three of us
misdiagnosed it as geometry or materials first. Offsetting every depth fetch by a
quarter texel fixed it.

Shadow bias vs. thin geometry. Cascade 0 had normalBias at 43.5 mm, so
anything thinner than ~9 cm cast no shadow at all — firepit legs, pegs, guylines.
Everything read as floating. Tightening lambda and moving to a per-fragment
slope-scaled depth bias brought it to ~9 mm.

MediaRecorder codec strings. 'video/mp4;codecs=h264' returns false;
'video/mp4;codecs=avc1.42E01E' returns true. Same codec, and I wrote off mp4
support for a while because of it.


Honest self-assessment

I had three separate LLM critics blind-judge the screenshots — source concealed,
asked to sort each into “photograph / AAA product screenshot / hobby project.”
Three rounds, all twenty shots came back hobby project every time. The recurring
complaints were visible instance repetition, large flat surfaces, and light not
interacting with the scene.

They did consistently identify the location as Japan though, from the maple leaf
shape, the susuki grass, the planted cedar stands, and the raised square firepit
(which exists because Japanese campgrounds ban ground fires).

So the reading is right, the rendering isn’t there yet. Feedback very welcome —
particularly on the vegetation and on getting large fabric surfaces to hold up.

5 Likes

I can’t seem to get the game to start? I see the info screen, but clicking doesn’t let me walk around…
edit: Ahh i wasn’t reading the directions right.. i had to spam f and stuff first…

Interesting app.. cool that you put up the pure result and not a manually cleaned up one. It shows a few issues that I have seen these models make a lot.

Things like.. the mouselook occasionally snapping in weird ways…
Aggressive AO.. UI elements shoving offscreen.

Very neat result!

Thank you — all three of these were real, and the first one was embarrassing enough that I want to write it up properly.

“I can’t get the game to start” — that was my fault, not yours. The controls were on screen the whole time. The boot screen says W A S D で歩く / マウスで見まわす / F で焚火, and the prompt above the firepit says F 火をおこす. You just couldn’t read any of it.

The cause was one line:

let lang = lsGet(‘kc.ui.lang’, ‘ja’); // never looked at navigator.language

The default locale was hardcoded, so every first-time visitor anywhere in the world got the Japanese UI. The English strings were all there — 134 of 134 keys — and simply never used. Worse, the two surfaces that matter most for a first-timer weren’t going through i18n at all: the boot screen (it renders before the JS modules load) and the contextual action prompt. So even after I fixed the default, “F 火をおこす” would still have been Japanese.

All three are fixed and deployed.

“mouselook occasionally snapping” — also real. I was accumulating movementX/Y without any sanity check. At mouseSens = 0.0022 rad/px, a single spurious 2000px event is 252° in one frame; a genuinely fast flick is ~150px. Now I drop the first event after pointerlockchange and clamp each event to ±180px.

“Aggressive AO” — you were right, and it took a measurement to find which AO. The wide AO moved the image by ±0.5/255; the contact term moved the dark end by ~8%. Rendering the difference as an image made the shape obvious: a dark band along every guy line, pole and tent seam. That came from mixing in the maximum horizon rather than the average — which I had added deliberately, because averaging makes a 12mm chair leg mathematically invisible (0.6% darkening before the change).

One methodology note that cost me a while: I first tracked peak darkening and it wouldn’t budge — across ~900k pixels the maximum is pure noise. Switching to “fraction of pixels darkened by ≥8/255” gave a signal that actually responded: 21.1% → 19.1%, against 17.9% for the build before I made it aggressive.

I have tried a couple of times and I can’t get it to load. It stops loading (or become unresponsive) at around 50% loaded. I use a PC with a Chrome browser.

One suggestion - you should make the link to your program easier to find. Perhaps just a line break so it is sitting on it’s own line (you can edit your original post).

MORE

I got it to work for a bit (it froze and then unfroze and froze again). You’ve done a very impressive job of generating all those textures and effects.

How did you create the tree leaves? I need more trees in my landscapes, but am worried about maintaining frame-rate.

One small quirk. It looked like clouds were swaying back and forth. There is no need to do that. Those are high cirrus clouds that should remain stationary.

This is great. I am a huge fan of procedural generation of shapes and textures. I have not dived in procedural audio … yet.

Your visual are very good.

But the waiting time is too much – I had to wait maybe 3-4 minutes until the image shows and then it was somewhat too slow to walk or look around.

Initially I thought your computer is much faster than mine, but then I observed something interesting – your snapshot is on the left, mine is on the right – see the contour of the tent opening – your is too “pixelated”, mine is smooth. Maybe you run in some special fast but low-quality GPU mode?

1 Like

Both of you hit the same wall from different sides, and it turned out to be one structural mistake plus one bug of mine. Fixed and deployed — details below, because the bug is the kind that would bite anyone generating assets at runtime.

The freezing (@phil_crowther)

You weren’t imagining the “froze, unfroze, froze again”. Boot runs 19 modules in sequence, and I only yielded to the browser between modules. Each module’s init was one unbroken block:

materials  3061 ms   ← one task
terrain    1807 ms   ← one task
vegetation  535 ms
everything else together ~1.1 s

On my machine that’s a 3-second hitch. On a machine 10× slower it’s 30 seconds of a completely dead tab, which is exactly when Chrome offers to kill the page. The progress bar can’t move either — it’s on the same thread.

So I sliced the generation loops and yield when 48 ms have gone by. Same instrument, before and after:

before after
longest single task 3132 ms 1218 ms
tasks over 500 ms 4 1
longest a 10 ms timer was starved 3211 ms 1266 ms
total boot 6284 ms 6315 ms (+0.5%)

Total time barely moved, which is the point — the work is the same, it just stops monopolising the thread.

The bug worth passing on. My first attempt did nothing. I’d written the yield as scheduler.yield(), on the reasoning that it’s the modern API and resumes fastest. I put the slices in, and the long-task list still showed one 3053 ms block. Not shorter — identical.

scheduler.yield() resumes the continuation at user-blocking priority, ahead of the queue. Measured over 2 s of chopped-up work:

yield wall time 10 ms timer fired rAF frames
none 2000 ms 0 0
scheduler.yield() 2001 ms 0 0–11
setTimeout(0) 2175 ms (+8.8%) 48 82
MessageChannel 2004 ms (+0.2%) 20 40

Timers are starved completely — that result was identical across two independently written test harnesses. (How much rendering gets through varied between them, so I won’t state that part as firmly.) setTimeout(0) does release the thread but gets clamped to 4 ms after five nested levels, and you pay that on every slice. MessageChannel has no clamp and cost 0.2%. That’s what’s in there now.

If you’re chunking heavy work to keep a page alive, scheduler.yield() is not the tool. It’s for staying responsive to input, not for letting the rest of the event loop run.

The clouds (@phil_crowther)

Right on both counts, and the cause was sillier than the symptom. Valley wind swings ±43° — terrain channels gusts, so that’s deliberate. The cloud shader was reading that same surface wind vector. Cirrus sits 8–10 km up in a different air mass and doesn’t care what the valley is doing.

Split into surface and aloft wind; clouds use aloft, grass and smoke and water keep the gusty one. Measured over 240 samples: surface swings 44.4°, aloft 1.2°. I slowed the cirrus drift to about a third rather than freezing it — real cirrus does move, just steadily and slowly.

The leaves (@phil_crowther)

Nothing clever in the leaf itself; the framerate comes from what’s around it.

Shape is an alpha atlas drawn with Canvas2D at boot — 512×512, four tiles, two 5-lobe and two 7-lobe maple outlines from a lobe function with jitter. Petiole stroke, polygon fill with a radial gradient, then a main vein to each lobe tip with side veins branching off it, then ~120 translucent blotches for blemishes.

One detail that matters more than it sounds: after drawing, I dilate the RGB outward past the alpha edge by a few pixels. Without it, mip filtering pulls transparent black in from outside the leaf and every leaf gets a dark fringe at distance. Cheap to do, invisible until you don’t do it.

Rendering: leaves are cards on recursively grown branches, all InstancedMesh, alphaTest: 0.45 — no blending, so no sorting. Distance LOD is full → simplified → impostor → culled.

Wind is entirely in the vertex shader — three frequency bands (trunk low, branch mid, leaf high) plus gust propagation. Zero per-leaf CPU cost, which is what actually lets the count go up.

Two things I’d warn you about, both of which I got wrong first:

  • Check what your LOD is actually showing. I had impostors taking over far too close, and the cedars read as flat cardboard in every mid-distance shot for weeks. It looked fine in the code and wrong on screen.
  • Don’t use your shared RNG for per-instance variation. My scatter loop consumes one stream; adding a single rng() call for jitter shifted every subsequent plant’s position. I hash the world position instead — consumes nothing, and the same plant gets the same value every run.

The pixelated-vs-smooth thing (@PavelBoytchev)

Good eye, and it’s the opposite of what it looks like: your image is the higher-quality one. Mine is the cheap one.

Quality auto-selects a tier. A recognised discrete GPU lands on high, which sets pixelRatio: min(devicePixelRatio, 1.5). My screenshots come out of a headless capture at DPR 1. So you were rendering roughly 2¼× the pixels I was — smoother edges on the tent opening, and a proportionally heavier frame. I published the slow-machine screenshots, which was misleading of me.

Worth trying, in order:

?q=mid      one tier down, pixelRatio 1.0
?px=1       keep the tier, drop resolution scale only
?q=low      1024 shadows, no SSAO/volumetrics/TAA

If ?px=1 alone fixes the walking-around slowness, it’s fill rate and the tier logic is being too optimistic about your GPU.

The 3–4 minute wait is a separate problem and I can’t reproduce it — 6.3 s here. Since there’s nothing to download, that time is all CPU generating textures and terrain. Could you open the console and paste the [boot] 内訳 line? It prints every module’s cost, sorted. That would tell me whether it’s uniformly slow (a CPU gap I should adapt to) or one pathological step. Right now I’m guessing, and I’d rather not optimise the wrong thing twice.

Housekeeping

Link’s on its own line in the first post now — thanks, that was a fair complaint.

The other thing that came out of the first round: the UI was hardcoded to Japanese, so anyone outside Japan got a wall of text they couldn’t read. That’s fixed, but only the shell was translated at first — the recipes, items, the daily prompts and the notifications were all still Japanese once you got past the campfire. Those are English now too, so the game is actually finishable in English. Japanese terms that don’t have honest translations are kept and glossed rather than swapped out — hangō mess tin, 6 gō for rice, Konnyaku (yam cake). It’s a Japanese valley; sanding that off seemed worse than a parenthesis.

2 Likes

Well done!

It froze and unfroze a few times, but then seemed to run fine.

It gave me some error messages on the upper right side when I tried to light the fire, but I could not read them. Perhaps they were telling me that I was out of wood. I’m not sure if I ever saw the fire lit. I assume it is under the kettle?

Later, I kept hearing a distinct animal sound, but could not locate the source.

That got me to thinking that you might consider playing around with stereo sounds so that people can try to locate different birds which you have placed in the trees. You could provide users with binoculars to find and identify the birds.

Love this!

Latelly I’ve been trying to do the same with this city:

4 Likes

Thank you — and the city is beautiful. I read through #33906 and we’ve been walking into the same walls from opposite directions, so here are three measurements that cost me real time. All of them are specific to generating everything at runtime, and none of them were obvious to me until I had numbers.

Contact shadows: I was measuring the wrong thing

I saw the GTAO in your PR. Before that I had a hand-rolled contact term and it was quietly broken in both directions.

Averaging the horizon over sample directions makes thin objects mathematically invisible. A 12 mm chair leg measured 0.994 — six-tenths of one percent of darkening — because its solid angle is tiny. So I mixed in the maximum horizon instead of the average, which fixed the chair legs and put an 8 cm black skirt along every guy line, pole and tent seam.

The part that actually cost me the day: I was tracking peak darkening to tune it, and it wouldn’t move. I set the floor to a nonsense value (0.85) and the peak sat at ~190/255 regardless. Across ~900k pixels the maximum is pure noise — flame flicker and TAA jitter own it. Switching the metric to “fraction of pixels darkened by ≥8/255” gave a signal that responded immediately: 21.1% → 19.1%, against 17.9% for the build before I made it aggressive.

If you tune GTAO by eye on a still, that’s fine. If you tune it with a number, don’t let that number be a max.

Shader compile: compileAsync made my boot 2× worse

92 programs, all compiled in the first frame — a 1.4 s freeze at exactly the moment the progress bar reads 100%. The obvious fix made it worse: 8.9 s → 16.7 s.

compileAsync            5159 ms   programs   8 → 71
then frame 0 render     1411 ms   programs  71 → 155

Final program count went from 92 to 155, so 63 of the warmed programs were thrown away. Two state mismatches, both visible in the source:

  1. Render target. getParameters folds outputColorSpace = currentRenderTarget === null ? renderer.outputColorSpace : workingColorSpace into the program key. At compile() time the target is null (sRGB); at render time post-processing binds an HDR target (linear). Every material compiles twice.
  2. scene.environment. If you compile before the PMREM is in place, envMap presence differs and everything compiles twice again.

Binding a dummy 1×1 RT to match the key, and running one update pass first, fixed it. Chunking the compile 24 meshes at a time and polling isReady() brought the worst stall to 331 ms with zero stalls over 500 ms.

One trap worth knowing: passing a subtree to compile(subtree, camera, scene) makes three gather lights from both arguments and count them twice — 26 wasted programs. I pass a thin proxy that enumerates only meshes.

scheduler.yield() does not release the main thread

Unrelated to shaders, but it burned an afternoon. I sliced my heaviest generation loops and yielded with scheduler.yield(), on the reasoning that it’s the modern API. The long-task list was identical afterwards — one 3053 ms block, not shorter.

It resumes the continuation at user-blocking priority, ahead of the queue. Measured over 2 s of chopped work:

yield wall time 10 ms timer fired
none 2000 ms 0
scheduler.yield() 2001 ms 0
setTimeout(0) 2175 ms (+8.8%) 48
MessageChannel 2004 ms (+0.2%) 20

Timers are starved completely — that result held across two independently written harnesses. setTimeout(0) works but gets clamped to 4 ms after five nested levels, and you pay it every slice. MessageChannel cost 0.2%. For a generator that runs for seconds, this is the difference between a live page and one Chrome offers to kill.

The one I lost

I spent a round trying to get dappled light onto the forest floor. I found two genuine structural bugs — frustum culling was removing the shadow casters (they sit upstream toward the sun, outside the camera frustum) and the cast distance was 19 m when at 32° sun elevation the tree shading your feet is 32–44 m away. Fixed both, shadow casters went 84 → 118, triangles +16%.

The image didn’t change. Mean difference 2.36/255, against a ~3.0 noise floor for re-shooting the same build.

The canopy is closed: sky visible through it, measured, is 0.04%. Dapple is made by gaps, not by shadow casters. I reverted the change and left the measurement as a comment so the next person doesn’t spend the round the same way.

Anyway — thank you for looking. Procedural everything is a genuinely different discipline from asset pipelines, and most of what I got wrong I only found because I forced myself to put a number on it.