Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证GPU-native 3D falling-sand sandbox (The Powder Toy in 3D) built on three.js / WebGL2
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗Play it in your browser: tpt3d.codyh.xyz
A GPU-native, 3D falling-sand sandbox in the spirit of The Powder Toy, built on three.js (WebGPU). Every cell of a 128³ grid (2.1M cells, up to 160×96×160) is simulated and raymarched on the GPU, at ~240 sim steps/s.
npm install
npm run dev # http://localhost:5173 (?preset=lab|volcano|empty&size=64|96|128|wide|world)
The games this one borrows from. When a feature needs a rule or a number, we take it from one of these first.
, class picker, the Spy's backstab, the Soldier's rocket launcher and rocket jumps.Q) for a sandbox you play in.Press ? in the app for the full list.
| Painting | |
|---|---|
| Left-drag | paint with the selected element or tool (the brush stays at the height where you clicked) |
| Right-click | erase on release; moving 6 pixels or more looks around instead and erases nothing |
[ ] or Shift + scroll | brush size |
| Shift + left-drag | a line: the brush swept from where you pressed to where you let go (TPT's Shift-drag) |
| ⌘ + left-drag (Ctrl on Windows) | a box between the two corners, as tall as the brush (TPT's Ctrl-drag) |
| ⌘ + Shift + click | flood fill (TPT's Ctrl+Shift-click): see Editing tools |
B / X | sphere or cube brush / replace mode: paint over existing material (also ; or Insert, as in TPT) |
| Delete | specific delete: the brush erases only the target element (also ⌘; as TPT's Ctrl+;) |
⌥ + click a tile, or ⌥I | make that element the target that replace mode and specific delete keep to |
⌘P | the Property tool: set the temperature, element, ctype, life, velocity or pressure under the brush |
I | eyedropper: pick the element under the cursor (or click the Eyedropper in the dock, then click the scene) |
| ⌘Z / Ctrl+Z | undo the last stroke, clear or scene change |
/ | find an element |
| Ctrl+B | show or hide decoration (TPT's key) |
| Camera | |
|---|---|
| Hold right mouse and move | look around, as in first person (Roblox Studio's and Unreal's editor camera) |
W A S D | fly where you look (Garry's Mod's noclip; hold Shift to go faster) |
E / Q | fly up / down (Roblox Studio's keys) |
| Scroll while holding right mouse | fly speed (Unreal's) |
F | fly to what's under the cursor (Roblox Studio's focus key; in the body F kicks) |
| ⌥ + drag | orbit around the point you click (Blender's and Maya's; also the trackpad orbit) |
| Shift + right-drag or middle-drag | pan |
| Scroll | zoom toward the cursor (Roblox Studio's and Blender's) |
R | reset the camera |
V | drop into the world as a person, or pop back out (Garry's Mod's noclip key) |
| Everything else | |
|---|---|
Space / . | pause / step one frame |
Y / U | cycle the air mode / turn ambient heat on or off (The Powder Toy's keys; see Simulation modes) |
1–5 | switch view |
T or the spawn menu's button | hide or show the spawn menu, open by default like The Powder Toy's element bar; in first person Q opens it (Garry's Mod's key). In Factory it's Build |
, | settings |
F2 | save a screenshot (Minecraft's key; Fn+F2 on some keyboards) |
| ⌘S / Ctrl+S | save the game (in first person ⌘ only, Ctrl being the camera key there) |
| ⌘O / Ctrl+O | the Saves list: load a save, export it, open a file |
` | the Lua console (TPT's): run commands, or code every step |
Hovering shows the element, temperature and air pressure under the cursor. Settings are remembered between visits.
The Powder Toy's editing tools, in 3D (TPT's Editing.cpp, PropertyTool.cpp, GameView.cpp):
X (or TPT's ;) paints over existing material. Delete (TPT's Delete, or ⌘;)
turns the brush into an eraser. ⌥-click an element tile, or point at one and press ⌥I, to make it the target: then
replace mode paints over only that element and specific delete erases only it (TPT's Ctrl+Alt-click). With no target,
X paints over anything, as before. One of the two modes is on at a time; the HUD and the menu say which.P). Pick a property and type a value in the menu: an element by name or
abbreviation for the element or ctype, a temperature in °C (or end it with K or F), a number for life, a velocity
in cells per step, or an air pressure. Values out of range turn red and paint nothing. It changes only matter, as TPT's
does; pressure also changes the air. Setting the element to air erases.Press F to drop a body onto the surface under the cursor. The camera swoops down into its eyes and the world stays
running around you. You're about 5½ cells tall (one cell is roughly 30 cm), so a lava flow is a river and a house is
a building. F again swoops back out.
The body is as mortal as a sand grain. You float or sink by density (hold Space to keep your head out of water), blasts throw you along the pressure gradient, and heat, lava, cold, acid, drowning, being buried and being thrown into walls hurt (falls don't, as in Noita). When you die, the camera pulls back and shows what killed you, then you respawn where you dropped in.
| In first person | |
|---|---|
| Mouse | look (click to capture the mouse, Esc to release it) |
W A S D, Shift | walk, sprint |
Space / C | jump or swim up / swim down |
| Left / right click | use the tool / its second action |
1–5 or scroll | pick a tool |
V | first or third person |
The tools are physical and finite. Infinite painting stays in the god view.
Nothing a tool carries is made up: the cells it takes come back out exactly (same element, temperature and state).
Everything you do makes a sound (synthesised with ZzFX and placed in 3D):
impacts sound like the material they hit, pitched by its hardness, and the world goes muffled under water. Digging
(the shovel, the pickaxe, the axe, a load set down) plays recordings instead: Kenney's CC0 Impact
Sounds, level-matched, the blade's bite over its material's texture, five takes a sound (public/sfx/dig). Shots
kick the camera, nearby blasts and hard landings shake it, and three.quarks
draws the muzzle flash, sparks, dust and tracers. The held tools are low-poly and flat-shaded in the RuneScape style, and
Settings → First person picks the body: a male wizard, a female wizard, or that guy with a marker-drawn paper bag on his head, animated with
Quaternius's CC0 animation library.
Every death goes up in a killfeed in the top right, Garry's Mod's death notices with its layout, timing and colours
(src/pov/killfeed.js): Attacker [weapon] Victim, or only the icon and the victim when you did it yourself or the world
did. The icon is the killer's weapon, or what the death's cause names (fire, acid, drowning, a fall). Your kills also
earn Halo 3's medals, in the sandbox or a match (Double Kill, Killing Spree, Splatter…), plus five for the sim's chaos
(Own Goal, Bored, Chain Reaction, Heavy Particles, Flooded); see docs/modes.md. An announcer calls them, and the match's
moments, in a voice from ElevenLabs (its stock voice Brian on Eleven v4, directed to boom), read once
by tools/announcer-voice.mjs into public/announcer/; a line without a clip yet is spoken by the browser's own voice. The kill icons, the
medal badges' icons, and the map menu's gamemode and map icons, are from game-icons.net
by Lorc, Delapouite, Skoll, Sbed and Carl Olsen, under CC BY 3.0,
recoloured and with their backgrounds removed (tools/killicons.mjs, tools/medal-icons.mjs and
tools/mode-icons.mjs bake them). So are the notices' icons (info,
warning, undo, light bulb, broom and camera, by Delapouite and Lorc; tools/noticeicons.mjs): Garry's Mod's notices,
dark tabs that slide in at the bottom right (src/ui/notices.js), with its "Undone Prop" habit of naming what an undo
took back. Holding Tab shows Garry's Mod's scoreboard (src/ui/scoreboard.js): the map's name, then a row per
player with kills, deaths and ping (BOT for bots), pink while dead.
The site opens on a map menu, Garry's Mod's New Game screen: gamemodes down the left with their icons, a square
preview of each map built for the chosen one, its size tagged in the corner, and Start. Sandbox lists every map. A map is one
place at one size: the boxes (Lab, Volcano, Islet and Empty at 128³, Dam Valley at 256 × 96 × 128) and the
worlds (Island, Tinker Creek, Volcano Isles, Lab Complex, Giant Volcano). The game itself only loads once
you pick one. Esc (with nothing else open), the toolbar's map button or Settings → Map → Change map
brings the menu back over the game. The maps are in src/maps.js. tools/map-thumbs.mjs renders their previews
into public/maps, and tools/menu-check.mjs checks the menu on the GPU. A link with a query (?map=lab,
?preset=…, an invite) skips the menu.
Start brings up a plain loading screen over the map's art: the gamemode, the map's name, a bar, and the one step the
boot is on now (download, build, shader compile, world, the match being set up), as GMod's loading screen names it.
The player stands beside it: "This is you." He is the real body, live (src/ui/youViewer.js, drawn in a worker so he keeps moving while the boot holds the page): breathing in his idle clip and turning slowly, and a click and drag turns him left or right, as on Skyrim's loading screen. A render of him (tools/you-portrait.mjs) stands in until he is up.
tools/loading-screen-check.mjs checks the menu's icons and the loading screen on the GPU.
Tinker Creek (?map=tinkerCreek) is an Animal Crossing-inspired Sandbox island with cottages, an orchard,
river bridges, terraces and a seaside dock. Its elevated home camera uses the normal Sandbox controls.
Animal Crossing mode adds only a rolling-log horizon. Select it in the map menu,
or open ?map=willowCove&mode=animalCrossing. Sandbox keeps the flat view.
Physics, collision and physical ray queries stay flat. No life-sim systems are included yet.
Mode metadata selects an effect through GAMEMODES.effect in src/maps.js.
src/gfx/presentation.js owns effect selection, material hooks and cleanup.
The shared math serves terrain, meshes, depth, screen picking and temporal reprojection.
Other modes can select the same preset without adding mode checks to shaders.
Map thumbnails stay neutral. Material lighting stays in the flat physical world.
The curved renderer uses bounded local chords, not curved physics or a screen-space image warp.
Run node tools/check-presentation.mjs for CPU checks.
The GPU checks are tools/presentation-check.mjs and tools/presentation-visual-check.mjs; both accept --port.
A world map swaps the box for a whole island: 1024 × 128 × 1024 cells (about 300 m across), generated from a seed with hills, cliffs, beaches, meadows, forests and rock peaks. Only a 128³ window around you is simulated (the orbit target in the god view, your body in first person), and it slides along 16 cells at a time as you move. Whatever you change stays changed: the bricks you leave behind are compressed and kept, and they come back when you return, so a house you built or a crater you blew is still there. Painting, tools, signs and undo work inside the window. Multiplayer doesn't work in World yet. The Giant Volcano's world is three times as tall (1024 × 384 × 1024, the window as tall), so its cone rises 350 cells (105 m) at the box volcano's steepness.
The dock groups elements like a periodic-table strip, each tile in the element's colour with a TPT-style abbreviation:
All element properties live in one table (src/elements.js) that is baked into the shaders as GLSL constants,
and how each one is drawn in another (LOOKS in src/gfx/materials.js). Adding an element is a row in each plus a palette
entry; the checklist at the top of src/elements.js lists what is data and what still needs code.
The rules around them (latent heats, pressure diffusion, collision restitution, tool strengths...) live in src/physics.js,
which reaches the shaders as #defines.
Each element tile is a tiny live scene. Hover it and a CPU port of the same engine (src/ui/tiles/) runs a small box of that element,
with the same table, the same constants from src/physics.js, and the game's gravity, speed and flow settings.
The cursor uses the game's own tools: Pressure on powders and liquids, the element's own brush on gases, and Heat on solids.
A new element with only a table row needs nothing else. One that gets its own special case in the GPU passes needs the same case
in src/ui/tiles/engine.js; node scripts/check-tile-engine.mjs lists any that are missing.
The spawn menu's Deco tab is The Powder Toy's deco menu: paint a colour onto matter without changing what it is. Decorate (TPT's SET) paints the picker's colour, Clear decoration takes it off, Add, Subtract, Multiply and Divide nudge the colour a little per frame the button is held (TPT's 0.01 strength), and Smudge blends the paint around each cell, averaged in linear light as TPT does. The colour's alpha is how strongly the paint covers: TPT blends by it. Ctrl+B (or Show decoration under the picker) hides it all and shows it again; the paint stays either way.
The paint belongs to the matter. Sand that falls keeps its colour, painted ice melts into painted water, painted sand melts
and sets into painted glass, and painted wood burns down to painted ash, because TPT's transitions keep it
(part_change_type). A cell that empties loses it, and so does a
cell the brush or a construction gives a new element (TPT's create_part starts a particle fresh). Undo brings it back, and
in World it travels with the window: bricks of paint that leave the window are kept and come back with it.
It is stored as one RGBA8 word per cell beside the state (src/deco.js), in the state's slot order, in a buffer the move
already binds: the move carries each block's eight words along with its cells (src/shaders/move.js; the rules are in
src/shaders/deco.js), and react never touches it. The words, their kernels and the move's share of the work only exist
from the first deco stroke, and a load switches them off again, so a world nobody paints pays nothing. The renderer
(src/shaders/gfx/deco.js) reads the words directly and turns a painted surface's albedo toward the paint (keeping its
texture's light and dark), makes painted liquid and glass scatter the paint's colour, and leaves glow alone,
so hot metal still shines through its paint. Smooth surfaces take the colour of the cell their material comes from, so a
paint edge on sand follows cells. Smoke, steam and fire don't show paint in the realistic view yet (the plain view tints
them), reflections and bounced light don't see it, and guests in a multiplayer session neither see nor paint it: the host's
stream doesn't carry it yet.
Constructions are whole structures placed with one click (src/constructions.js). Unlike TPT's stamps they are generators:
each one is built from a seed, a size (the brush size) and a variant, so every tree is different. With Shuffle selected a
random variant is picked per placement, and New seed rolls a different one. A ghost of the exact model follows the cursor
and turns its front (the door) toward the camera.
They are made of ordinary elements and behave like them: wooden walls burn, the stone chimney draws smoke up from the fireplace,
an igloo melts, a powder keg goes off. Placing one uploads it as a small 3D texture that a single GPU pass (src/shaders/stamp.js)
writes into the grid. Solid bases grow a footing straight down to the first thing that can bear weight (up to 32 cells), so a house
on a ledge gets a plinth and one in a lake stands on stilts.
Every construction is a small program written against one API (src/constructions/runtime.js: put, box, ball, disc,
rod, ...). The built-ins in src/constructions/builtins.js use it, and so can a model, a chatbot or a coding agent:
src/constructions/lint.js: liquid that can leak through
diagonal gaps, unsupported powder, clones with no source). The report and two pictures go back to the model until the build
is clean. Models come through the Vercel AI SDK with your own key (src/ai/providers.js): OpenRouter (with Sign in with
OpenRouter), Anthropic, OpenAI, Google, or a local model through Ollama, LM Studio or any OpenAI-compatible server. Keys
stay in your browser. Without a key, Copy prompt gives a prompt for any chatbot and Paste code runs its reply.
Subscriptions (Claude, ChatGPT, Gemini) work through the MCP server below. Your constructions are saved, and export and
import as .json.relay/ai.js): one
generation a day without an account, 10 a day signed in with Google, 100 on the paid plan. The relay runs the
sign-in (relay/auth.js) and sends the page back with a session token in the URL fragment; src/account.js keeps it in
localStorage (no cookies) and sends it as Authorization: Bearer to /auth/* and the AI proxy. A random nonce in the
return URL and in sessionStorage means a token only counts in the tab that asked for it. Settings → Account signs
out or deletes the account, and public/privacy.html says what's stored. Against a local relay
(npm run relay) there's also Dev sign-in, which skips OAuth; it needs AUTH_DEV=1 in relay/.dev.vars.npm run construct -- my-thing.js --png out.png runs code headlessly and prints the lint report;
npm run construct -- --builtins lints every built-in; npm run mcp:construct serves the same tools over MCP.
See docs/constructions.md.Per simulation step there are three GPU passes over the state (two RGBA32F textures in a brick-major 2D atlas: each 4×4×4 brick is an 8×8-texel tile, see src/shaders/common.js):
1. Movement: a Margolus block cellular automaton (src/shaders/move.js).
The grid is split into 2×2×2 blocks whose partition shifts by one cell every step.
Each block is solved once (one fragment per block, written to 8 MRT attachments as "which cell lands here and with what velocity").
A gather pass then rebuilds the grid. Blocks only permute their cells, so mass is conserved exactly
and two particles can never race for the same cell, which is the usual problem with GPU falling sand.
Movement is velocity-driven. Each particle carries a velocity (cells/step) and tries to move along each axis with probability |v|. Density decides whether it can displace its neighbour, so sand sinks through water, oil floats, steam bubbles up, and snow and ash float.
2. React (src/shaders/react.js): everything local.
min(cond_a, cond_b)·ΔT) divided by volumetric heat capacity, so energy is conserved.
Conductances are in realistic ratios: air insulates (~1/60 of rock), and metal conducts.3. Brush (only while painting).
Simulation modes (src/simModes.js): The Powder Toy's Simulation Options, in the Settings drawer's Simulation
section. They belong to the world: a new scene or map starts them over (vertical, on, on, solid), and in multiplayer the
host sets them.
GetGravityField). The move pass's rules are written for down = −y, so each 2×2×2 block is solved
turned so that −y is the dominant axis of its pull, and the react pass applies the full vector, with its normal force,
friction and liquid flow along that axis. A pool has no level in radial gravity, so a drop's surface keeps churning.Y): on; pressure off (held at 0: blasts heat and burn but throw nothing); velocity off (still air: no wind or
convection, and matter isn't pushed by pressure, since in TPT particles feel pressure only through the air's velocity);
off (both); no update (pressure and wind stay where they are, without spreading or fading).U): off holds the air at 20 °C, and matter trades no heat with it, so nothing cools in air and
convection carries no heat. TPT starts with it off; here it starts on, as the world always was.Radial gravity and void or loop edges switch off quiet-brick skipping (below): its rest test knows only vertical
gravity and walls, so every brick is stepped. Photons and neutrons, the first-person body and constructions don't wrap.
tools/sim-modes-check.mjs checks every mode on the GPU.
Quiet bricks (src/shaders/activity.js). Most of the box is still air or resting matter, and stepping it only
reshuffles the air's jitter. A cell is inert when stepping it could change nothing beyond the rest tolerances: air near
ambient with no wind or pressure, or matter at rest with nowhere to fall, flow or topple, nothing that reacts, and no
neighbour off its temperature by more than the tolerance. Every pass that writes the state leaves a byte of activity
flags beside each cell (its own rest test, its neighbour test as the react pass saw it, and whether it changed in a way
its neighbours' tests read), so every couple of steps the activity map decides most 4×4×4 bricks from those 64 bytes
and re-tests from the state only where something nearby changed. A brick whose 26 neighbours are inert too is skipped
by the move and react passes. A change travels at most two cells per step, so nothing can reach a skipped brick before
the next map.
The simulation stays a blocky cellular automaton; the renderer draws it as continuous matter.
Render fields (src/shaders/fields.js, rebuilt once per frame, never read by the sim). Each element has a look
(src/gfx/materials.js): liquids, lava, powders and natural solids (wood, plant, rock) belong to a smooth-surface channel;
smoke, steam and fire are media; wall, metal, glass and clone stay crisp voxels. A look that names no channel gets its
kind's (KIND_LOOK: powders a granular heap, liquids the liquid surface, gases a mist), so only solids are ever crisp by default. Per channel, cell occupancy is
smoothed over time (so cells swapping every step don't shimmer), blurred with a per-channel Gaussian, and normalised by the
blurred weight of non-crisp cells, so walls and the floor count neither way: a one-cell water film keeps its height and
surfaces meet walls cleanly. Features the blur would dissolve (a lone drop or grain, a falling stream, a one-cell trunk)
are scaled back up next to current matter so their surface sits half a cell from the cell centre; ghosts of cells that
moved on still fade. gfx.smoothing (src/gfx/uniforms.js) scales every blur radius (0 = off).
Tracer (src/shaders/render.js + src/shaders/gfx/*). An Amanatides–Woo DDA walks the grid, skipping empty 4×4×4 bricks
(the brick map is built from the blurred fields, so it's dilated for free). In each cell it root-finds where a field crosses
0.5 and shades that point:
scatter in gfx/materials.js), and sunlight fades
with depth: water is clear when shallow and blue-green when deep, oil amber, acid a milky green, ice cloudy. Thin
liquid (drops, streams) is read as a cubic B-spline instead of trilinearly, so drops are round lenses, not faceted gems;gfx/surface.js), the
material blended between neighbouring cells; lava grows a cooling crust with glowing cracks. Sliding sand carries its
texture with it, using Portal 2's flow-map technique: the texture is pushed along a flow field of how fast grains have
actually been moving (moveFlowFrag in shaders/move.js), in two crossfaded layers that reset in turn so it never
stretches. Still piles have no flow, so their texture stays put;gfx/media.js), sampled on a jittered lattice along the ray with sub-cell
noise that curls and frays them and rises with the gas, so a lone cell is a faint wisp, not a sprite. They scatter
sunlight forward (Henyey–Greenstein, plus multiple-scattering octaves), shade themselves, and are lit by the sky and
the glow; flames are soot sheets in rising tongues that emit blackbody light, hotter in the core.Light (src/shaders/gfx/lighting.js, src/gfx/sky.js, src/shaders/gi.js). The sky is a clear-sky atmosphere
(single Rayleigh and haze scattering, integrated in closed form along the view ray) that also sets the sun's colour, warmer
as it sinks. Day turns to night as the simulation runs (src/gfx/daylight.js: one day is 72,000 steps, about five
minutes at the default speed, and it holds still while paused; Settings → Lighting sets the time); after sunset a full moon lights the scene through the same
sky, dimmed and shifted blue the way a night-adapted eye sees it. A per-frame voxel shadow map is traced from the sun with the same surfaces. It records the opaque depth plus
optical depth through liquids, glass and gas, so water casts tinted shadows and smoke casts soft ones. Shadows soften with
distance from their caster (PCSS: the sun is a disc); at a contact edge within a texel, an exact DDA ray toward the sun
settles it. Indirect light comes from one probe per 4×4×4 brick: every frame, rays from the probes march the brick map and
collect the sky, the ground, the light bounced off lit matter (fed back over frames, so bounces add up) and the light
glowing matter gives off, so lava or crystal lights a whole room by bounce, stored as L1 spherical harmonics with the
sky's visibility. Surfaces take their ambient light and blurry reflections from the probes,
plus near-field occlusion from the fields; polished ones still see the sky itself where it is open.
Three upgrades sit on top, each switchable in Settings → Lighting (all on by default). Contact shadows and bounce: two
short rays per pixel walk the voxel grid; a ray that hits matter within six cells sees that matter's own sunlit, probe-lit
colour, and one that escapes falls back to the probes. Caustics: where the shadow map says a point lies under a pool,
the ripple height field's curvature focuses the sunlight reaching it (crests brighten, troughs spread), blurred with
depth by the sun's disc. Lava and fire as lights: each pixel picks one nearby emitting brick, weighted by its power over
distance², and traces a shadow ray to it, so the glow gets a direction and casts shadows.
Glow. Anything above ~500 °C glows (src/gfx/incandescence.js): the colour is Planck's law through the CIE colour matching functions, the brightness is the
physical luminance compressed by a power law, so steel reads dull red at 600–700 °C, cherry to orange at 800–1000 °C and
molten rock outshines daylight. Surfaces emit what they don't reflect (Kirchhoff), and the open skin of hot rock runs cooler
than its cracks; hot steel grows a patchy black scale. Some matter also glows by itself at any temperature (a material's
emit in src/gfx/materials.js): crystal is fluorite, lit by the blue-violet band its europium gives off under ultraviolet
light, here without the lamp. One function, emission(), adds that to the thermal glow for every path that draws matter's
light. Exposed glowing faces feed a coarse light volume that lights the surroundings. A crystal's face shows the glow
from inside it: only the share inside its escape cone leaves through a face, the rest is trapped and guided out at its
edges, and it bands with the fluorite's colour zoning.
Eyes adjust to the dark (src/gfx/post.js, ADAPT): histogram auto exposure as in Unreal and Frostbite, only ever
upward. A view over a set brightness keeps the fixed exposure untouched; in a cave or at night the exposure rises by up
to 4 stops, over a few seconds into the dark and faster back into the light (Pattanaik et al. 2000). The raymarcher writes depth, so three.js lines and the brush composite correctly.
Empty space is crossed in jumps: a distance map over the bricks (brickDistFrag in src/shaders/passes.js) says
how far each empty brick is from anything, and camera, shadow, sun and reflection rays leap that whole empty cube at once.
On demand (src/gfx/pacing.js). Frames are capped at 60 per second (a 120 Hz screen would otherwise double every
frame's work and the simulation speed). The passes rebuilt from the state (fields, bricks, shadow map, GI) only run when
the state, sun or their settings change, plus the few frames their temporal filters need to settle; the view only
renders when the camera, scene, settings or state change, or on input, until TAA has converged. A paused, still scene
costs no GPU work at all. Code that changes the picture in ways the frame loop can't see calls __app.requestRender().
Post (src/gfx/post.js): linear HDR → TAA (Halton jitter, reprojection, variance clipping) → energy-conserving bloom →
AgX tone mapping. Bright saturated light (lava, flames) blends toward the same curve per channel, so it runs through
amber and gold to white like film instead of fading to pale peach. The data views skip the tone curve so their legend
colours stay exact.
Profiler (Settings → Developer; src/gfx/profiler.js, src/ui/profiler.js). An overlay with a GPU waterfall of one
frame per second, CPU time per phase, fps, steps/s, the share of bricks the simulation steps, a GPU memory estimate, and
Copy for a plain-text report. GPU timer queries misread on Apple GPUs, so in the sampled frame the CPU waits for the GPU
after every pass (a one-texel read of the target it wrote) and subtracts the calibrated cost of that wait. Other frames
run unsynced, and a still scene stays idle. When the profiler is off, it costs nothing.
Number keys switch between six views (the views menu shows a live thumbnail of each). Colormaps live in src/views.js and are baked
into the shader, so the on-screen legend always matches.
| Key | View | Shows |
|---|---|---|
1 | Realistic | sunlight, shadows, see-through water, glowing hot things |
2 | Heat | temperature, from blue below freezing through grey at room temperature to white-hot; warm air glows |
3 | Pressure | the air pressure field as a cloud, and where blasts hit surfaces |
4 | Flow | what's moving and which way: falling, sliding, rising, plus moving air |
5 | X-ray | everything see-through in its own colour, denser materials more solid |
0 | Plain | the original look from before the smooth renderer: flat-coloured blocks, sunlight, shadows and glow |
Pick the SIGN tool and click any surface to pin a label (Enter to save, Escape to cancel, click a sign to edit, × to delete).
Signs can show live values from the cell they're attached to: {t} temperature, {p} pressure, {e} element.
A small GPU probe pass reads those values and dims signs that are hidden behind voxels.
⌘S (Ctrl+S) or the floppy in the toolbar saves the game, named for its map and the time, with a picture of the view.
The map menu (Esc, or ⌘O) lists your saves under Saves, Garry's Mod's menu item: load one, export it as a
.tpt3d file, delete it, or Open file… to load a file (it joins the list). Saves live in the browser (IndexedDB),
so export the ones you want to keep elsewhere.
A save is exact: every cell's element, temperature, life, ctype and colour seed, its velocity and pressure, as the GPU holds them. It also keeps the signs, entity placements (legacy spawners included) and perk orbs, the fast particles in flight, the time of day, gravity and speed, and where the god camera was. What derives from the cells (activity, render fields, light) is rebuilt on load. The body, NPCs and vehicles are left out: you come back in the god view.
In a box, loading is a scene change, so ⌘Z takes it back (as TPT snapshots its history before opening a local save). In a World, a save holds the seed and scene, the window as it was and every edited brick the island keeps, so loading puts you back on your island as you left it; like any world load, it can't be undone. Edits you made away from the window show from afar again once you pass back over them. When playing together, only the host loads; a guest can save a copy of what it sees (without velocity or pressure, which guests aren't sent).
The file is a short JSON header (version, name, date, map, size, thumbnail) and deflated sections, TPT's own design:
named fields that a reader skips when it doesn't know them, so old saves keep loading as features are added. A feature
adds its own with registerSaveSection(key, { save, load, absent }) (src/saves/sections.js), and settings that belong
to a world with registerSavedSettings(keys). A 128³ box is about 5–10 MB: the random per-cell colour seeds don't
compress. node tools/saves-check.mjs checks the format on the CPU.
` opens a Lua console, The Powder Toy's: real Lua 5.3 (through fengari,
loaded the first time you open it) with TPT's API and a z on every position, so
sim.partCreate(-1, 64, 100, 64, elem.DEFAULT_PT_WATR) makes a water cell and
event.register(event.beforesim, fn) runs fn before every simulation step. Writes are queued and applied in one
small GPU pass per step; a typed command reads the world exactly, and event handlers read a copy that refreshes
asynchronously, so a script never stalls the GPU. A handler that errors or runs too long is removed with a message,
and handlers share an 8 ms budget a frame. Only what you type or paste runs, and in multiplayer only the host's
console runs scripts. Other features add functions through a registry (src/console/registry.js).
The API, the limits and the registry are in docs/console.md.
Click the players button in the toolbar to host the current world. This copies an invite link. Guests see the host's world from their own camera, paint into it, and everyone sees everyone's brush with a name tag. Undo, scenes, grid size and pause stay with the host. If the host switches to another tab, the world pauses and guests are told. Clicking the button again opens the session panel, which lists the players, copies the invite link, and has Stop hosting (or Leave for guests). If the host leaves, guests keep a copy of the world and play on alone.
npm run relay # local relay on ws://localhost:8787 (needs wrangler); then npm run dev
The host runs the only simulation. About 10 times a second it packs the state on the GPU into 4 bytes per cell,
keeping only what guests draw (element, an 8-bit temperature, smoke and fire density, what lava melted from). It reads that back without stalling,
XORs it against the last frame it sent and deflates it. A 128³ world is a 30–80 KB keyframe to join and about 2–3 Mbit/s while things move.
The relay (relay/worker.js, one Cloudflare Durable Object per room) only forwards messages. It accepts pages from the site,
its Pages previews and local development (SITE_HOSTS and friends in relay/worker.js), and players can't forge its own messages.
To deploy it, run wrangler deploy --config relay/wrangler.toml. The production relay lives at wss://tpt3d-relay.codyh.xyz (set in .env.production).
The site deploys itself: every push to main on GitHub builds it and ships it to the Cloudflare Pages project tpt3d (served at https://tpt3d.codyh.xyz; see .github/workflows/deploy.yml).
Without VITE_RELAY_URL, production builds hide multiplayer.
Guests' lines, boxes, fills, Property values and replace targets go to the host with their strokes; a fill is worked out on the host's world.
Guests don't receive velocity, pressure or air temperature, so their pressure and flow views look empty, the heat view shows no warm air and flames look a little dimmer. Signs aren't shared.