Compare commits

...
12 Commits
Author SHA1 Message Date
44r0n7andClaude Opus 5.5 fbc3b07be2 Phase 4: play-through checked, final profile, dead code removed, next-step recommendation (t88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:50:54 -04:00
44r0n7andClaude Opus 5.5 7468d00efb Multi-core simulation: grain movement and heat across a worker pool (t88 phase 3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:23:43 -04:00
44r0n7andClaude Opus 5.5 20963c776e WebGPU renderer complete: dirty uploads, default limits, legacy removed, WebGPU required, warm-up (t88 phase 2d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:17:17 -04:00
44r0n7andClaude Opus 5.5 452b6aed32 Smooth look on WebGPU: WGSL port fed by GPU inputs and CPU overrides (t88 phase 2c)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:42:12 -04:00
44r0n7andClaude Opus 5.5 65054e691b Log t88 phase 2b: GPU lighting checks, profile, and the gate mistake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:06:39 -04:00
44r0n7andClaude Opus 5.5 31e3005816 Lighting on the GPU in the WebGPU path (t88 phase 2b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:01:15 -04:00
44r0n7andClaude Opus 5.5 fc05146887 WebGPU pixel look: GPU colours the world, CPU only rare cells (t88 phase 2a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:49:36 -04:00
44r0n7andClaude Opus 5.5 b2c9ad9533 Served with isolation headers; start-screen capability notices; tests on the real GPU (t88 phase 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:11:42 -04:00
44r0n7andClaude Opus 5.5 9781aa3ee1 Profiler: real-GPU breakdown by zoom, look and scene, with a baseline (t88 phase 0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:31:43 -04:00
44r0n7andClaude Opus 5.5 0c0edf46ca Decision: WebGPU renderer and multi-core simulation in the browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:04:58 -04:00
44r0n7andClaude Opus 5.5 0ad3561e6a Hide the system pointer over the game; the game draws its own aim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:43:49 -04:00
44r0n7andClaude Opus 5.5 f193b1c76c Identify ores with the cursor: pinged veins, ores-only label, vein outline (t77)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:43:49 -04:00
123 changed files with 91829 additions and 1530 deletions
+34 -5
View File
@@ -2,6 +2,10 @@
A falling-sand factory game in the browser, in the style of Sandustry. A falling-sand factory game in the browser, in the style of Sandustry.
WebGPU is required (Chrome or Edge with a supported graphics adapter). Both looks use WebGPU;
there is no fallback renderer. Browsers without an adapter, including Firefox on Linux in our
checks, show a notice on the start screen. Serve the page over HTTP as described below.
Every grain of sand, ore, water and fire is simulated. You fly a small bot over a side-view Every grain of sand, ore, water and fire is simulated. You fly a small bot over a side-view
world, dig with a resonance drill and vacuum up what breaks loose. You build furnaces from world, dig with a resonance drill and vacuum up what breaks loose. You build furnaces from
blocks, melt ore with coal and air, and cast the metal in molds. Then you sell bars, alloys and blocks, melt ore with coal and air, and cast the metal in molds. Then you sell bars, alloys and
@@ -15,8 +19,32 @@ python3 build.py
python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes
``` ```
Opening `dist/index.html` directly works too. The game is one self-contained page with no The game requires WebGPU and cross-origin isolation for shared-memory CPU workers.
downloads; sound and graphics are all generated in code. Saves stay in your browser. Grains and heat run across up to 16 helpers (two CPU cores are reserved); Settings →
Simulation → CPU workers caps the pool on the next world load. Small jobs run inline.
The game must be served: use `python3 tools/serve.py` locally, or serve `dist/` with any web
server that sends these headers. Opening `dist/index.html` from disk is no longer supported.
Use HTTPS when hosting remotely (localhost works over HTTP).
nginx, in the site's `server` or `location` block:
```nginx
add_header Cross-Origin-Opener-Policy same-origin always;
add_header Cross-Origin-Embedder-Policy require-corp always;
```
Caddy, in the site block alongside `root * /path/to/dist` and `file_server`:
```caddyfile
header {
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Embedder-Policy require-corp
}
```
These are plain static files, so a small server is enough. The game is one self-contained page
with no downloads; sound and graphics are generated in code. Saves stay in your browser.
- To keep or move a world, use Pause → Export world file. - To keep or move a world, use Pause → Export world file.
- To load one, use Settings → Load a world file on the title screen, or Pause → Import world file - To load one, use Settings → Load a world file on the title screen, or Pause → Import world file
during a game. World files exported from the claude.ai version load here too. during a game. World files exported from the claude.ai version load here too.
@@ -31,16 +59,17 @@ The controls are in the in-game notebook (N) under Controls. The basics:
## Develop ## Develop
You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots: You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots.
The golden-image tests also use ImageMagick (`magick`):
```sh ```sh
uv venv .venv uv venv .venv
uv pip install --python .venv/bin/python -r requirements.txt uv pip install --python .venv/bin/python -r requirements.txt
.venv/bin/python -m playwright install chromium .venv/bin/python -m playwright install chromium firefox
``` ```
If uv is not installed, `python3 -m venv .venv` and If uv is not installed, `python3 -m venv .venv` and
`.venv/bin/pip install -r requirements.txt` also work; then install Chromium as above. `.venv/bin/pip install -r requirements.txt` also work; then install the browsers as above.
After that, plain `python3` works for every command below; the browser scripts switch to After that, plain `python3` works for every command below; the browser scripts switch to
`.venv` on their own. `.venv` on their own.
+6 -2
View File
@@ -6,7 +6,7 @@ code) into one classic script, so they share one global scope and 'use strict' a
src/sim/base.js applies to all of it. No bundler, no dependencies. src/sim/base.js applies to all of it. No bundler, no dependencies.
Writes: Writes:
dist/index.html the full page: open it, or serve with python3 tools/serve.py dist/index.html the full page: serve with python3 tools/serve.py
dist/deepgrain-foundry.html the same without <!doctype>/<head>/<body>, for hosts (like a dist/deepgrain-foundry.html the same without <!doctype>/<head>/<body>, for hosts (like a
claude.ai artifact) that wrap the page themselves claude.ai artifact) that wrap the page themselves
dist/bundle-lines.json where each source file starts in dist/index.html, so a line number dist/bundle-lines.json where each source file starts in dist/index.html, so a line number
@@ -35,9 +35,13 @@ def main():
shell = (src / man['shell']).read_text() shell = (src / man['shell']).read_text()
css, _ = join(man['css']) css, _ = join(man['css'])
js, starts = join(man['sim'] + man['client']) js, starts = join(man['sim'] + man['client'])
sim, _ = join(man['sim'])
# Keep worker code outside the indexed script so source line mappings stay exact.
worker = '<script>const SIM_WORKER_SOURCE=' + json.dumps(sim).replace('</', '<\\/') + ';</script>'
head, tail = shell.split('//__JS__') head, tail = shell.split('//__JS__')
body = head.replace('/*__CSS__*/', css) body = head.replace('/*__CSS__*/', css)
page = body + js + tail page = body + js + tail
page = worker + page
pre = ('<!doctype html><html><head><meta charset="utf-8">' pre = ('<!doctype html><html><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
'</head><body>') '</head><body>')
@@ -46,7 +50,7 @@ def main():
(dist / 'deepgrain-foundry.html').write_text(page) (dist / 'deepgrain-foundry.html').write_text(page)
(dist / 'index.html').write_text(full) (dist / 'index.html').write_text(full)
# the script starts on this line of index.html (the doctype prefix has no newlines) # the script starts on this line of index.html (the doctype prefix has no newlines)
js_line = body.count('\n') + 1 js_line = worker.count('\n') + body.count('\n') + 1
(dist / 'bundle-lines.json').write_text(json.dumps( (dist / 'bundle-lines.json').write_text(json.dumps(
{'page': 'dist/index.html', 'files': [[js_line + s - 1, f] for s, f in starts]}, indent=1) + '\n') {'page': 'dist/index.html', 'files': [[js_line + s - 1, f] for s, f in starts]}, indent=1) + '\n')
print(f'built dist/index.html ({len(full):,} bytes, {len(starts)} source files)') print(f'built dist/index.html ({len(full):,} bytes, {len(starts)} source files)')
+7
View File
@@ -528,6 +528,13 @@ above, including:
rate, up to a cap per metal; power never sets hardness, so it can't skip the steel bit. It is rate, up to a cap per metal; power never sets hardness, so it can't skip the steel bit. It is
tuned like the drill and never harms anything built. It waits until power is switched back on, tuned like the drill and never harms anything built. It waits until power is switched back on,
since today every machine runs without power. since today every machine runs without power.
- **Performance: WebGPU and all CPU cores, in the browser (2026-10-27).** Zoomed out, drawing
is CPU-bound (about 26 ns per visible grain) and the simulation runs on one core. The owner
chose: WebGPU only (no WebGL fallback), tested in Chrome and Firefox with a clear message when
it's missing; the game must be served with COOP/COEP headers (for SharedArrayBuffer), so
opening the file from disk is dropped; determinism is re-baselined once and becomes the same on
any number of cores; as fast as possible, measured at every step. Grain movement and heat go
across a worker pool first; moving the whole simulation into its own worker is decided after.
## Ideas not built yet ## Ideas not built yet
+159 -69
View File
@@ -15,12 +15,13 @@ src/manifest.json the build order: simulation files, then browser files
src/shell.html page markup (HUD, menus, dialogs); CSS and JS are put in at the markers src/shell.html page markup (HUD, menus, dialogs); CSS and JS are put in at the markers
src/styles/ base styles and the dev console panel src/styles/ base styles and the dev console panel
src/sim/ the simulation. No DOM. The headless tests load only these files. src/sim/ the simulation. No DOM. The headless tests load only these files.
src/render/ drawing: the pixel look (CPU) and the smooth look (WebGL2), overlays src/render/ drawing: WebGPU pixel and smooth looks, rare CPU overrides and overlays
src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves, src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves,
audio, the frame loop audio, the frame loop
test/ headless suites (t_*.js) and their harness; browser tests (play.py, test/ headless suites (t_*.js) and their harness; browser tests (play.py,
t_smooth.py, t_dev_console.py); run_all.py t_smooth.py, t_dev_console.py, t_caps.py, firefox_smoke.py); run_all.py
tools/ serve.py (build, serve, rebuild on change), shot.py (screenshots), tools/ serve.py (build, serve, rebuild on change), server.py (isolated HTTP), shot.py (screenshots),
browser.py (shared launch/GPU choice), caps.py (capability report),
where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser
scripts to .venv), scenes/ (screenshot setups), experiments/ scripts to .venv), scenes/ (screenshot setups), experiments/
(measurements and reports) (measurements and reports)
@@ -148,18 +149,23 @@ The frame loop applies speed and queued steps while keeping the per-frame tick l
## Drawing ## Drawing
- **The pixel look:** Both looks require WebGPU. `cell.js` colours rare built geometry and `objects.js` adds bars,
- `drawWorld` (`src/render/world.js`) fills one pixel per grain; flyers and blueprint ghosts. The GPU canvas sits behind the transparent overlay canvas.
- `diagonal-screws.js` derives diagonal and straight end bends from `endJoin`, sharing casing geometry between pixels, smooth shading tags, ghosts and previews;
- `computeLight` (`light.js`) adds coloured light; - `gpu/device.js` compiles every pipeline asynchronously at startup, submits both looks during
- overlays (machines, molds, the bot, rain, arrows) are drawn on the 2D canvas afterwards world loading, and handles one device recreation followed by an autosaved Reload notice.
(`machines.js`, `overlays.js`). - `gpu/textures.js` owns world/view textures and bindings; `gpu/uploads.js` merges dirty 32x32
- **The smooth look** (`src/render/smooth.js`) takes the buffers `drawWorld` fills (colour and chunks into horizontal strips, retaining clean chunks across camera moves. Simulation writes
a class per cell, the colour behind, trees, fields, light, glow) and shades them with WebGL2. mark `renderDirty` in `sim/world.js`; hot chunks include the conduction halo.
- The cell classes are set in `world.js` and read in the shader: 4 is a crisp built piece, - `gpu/colour.js` colours plain grains. Smooth uses a second pass for its four outputs so no
6 a wall tile, 7/8 a belt, 9–12 a screw conveyor, 13/14 a filter. pipeline exceeds four storage textures. `gpu/cracks.js` supplies drill contour shading.
- On software GL it falls back to pixels, unless `window.__allowSoftwareGL` is set (the test - `gpu/light.js` computes sky, heat emission, lamp and light propagation. No CPU light sweep.
tools set it). - `gpu/shaders.js` provides pixel upscaling and bloom. `gpu/smooth*.js` shapes smooth grains,
field AO and glow; `gpu/surface.js` caches the parallax horizon.
- Smooth classes are set in `cell.js`: 4 crisp built piece, 6 wall tile, 7/8 belt, 9–12 screw,
13/14 filter, 19–22 diagonal screw, 23–30 slope belt/filter, 31/32 slope sidewall and cleats.
`diagonal-screws.js` and `slope-belts.js` share the simulation join geometry with pixel colours,
smooth tags, ghosts and previews.
## The browser shell ## The browser shell
@@ -183,6 +189,7 @@ The frame loop applies speed and queued steps while keeping the per-frame tick l
| Mouse building and dragging | `src/ui/actions.js` (`primaryPress`, `primaryRelease`, `secondaryPress`, `toolTick`) | | Mouse building and dragging | `src/ui/actions.js` (`primaryPress`, `primaryRelease`, `secondaryPress`, `toolTick`) |
| Cursor previews (ghosts, drill aim, selection box) | `src/ui/previews.js` | | Cursor previews (ghosts, drill aim, selection box) | `src/ui/previews.js` |
| Controls help text | `src/ui/notebook.js` (`controlsHtml`) | | Controls help text | `src/ui/notebook.js` (`controlsHtml`) |
| Cached vein membership, ore identification and label timing | `src/ui/cursor.js` |
| Hover info and discovery cards | `src/ui/info.js` | | Hover info and discovery cards | `src/ui/info.js` |
| Milestones | `src/sim/progress.js` (`MILESTONES`), checked in `src/sim/ledger.js` | | Milestones | `src/sim/progress.js` (`MILESTONES`), checked in `src/sim/ledger.js` |
| Starter furnace designs | `src/sim/progress.js` (`STARTER_DESIGNS`, `smelterBlueprint`) | | Starter furnace designs | `src/sim/progress.js` (`STARTER_DESIGNS`, `smelterBlueprint`) |
@@ -200,7 +207,7 @@ The frame loop applies speed and queued steps while keeping the per-frame tick l
| World generation | `src/sim/worldgen.js` | | World generation | `src/sim/worldgen.js` |
| Save format and migrations | `src/sim/save.js` | | Save format and migrations | `src/sim/save.js` |
| Machine drawing (silo, refiner...) | `src/render/machines.js` | | Machine drawing (silo, refiner...) | `src/render/machines.js` |
| Smooth-look shader | `src/render/smooth.js` | | Smooth-look shader | `src/render/gpu/smooth-shader.js` |
| Settings screen | `src/ui/settings.js` (settings and defaults: `SET` in `src/ui/state.js`) | | Settings screen | `src/ui/settings.js` (settings and defaults: `SET` in `src/ui/state.js`) |
| Sound and music | `src/ui/audio.js` | | Sound and music | `src/ui/audio.js` |
@@ -237,19 +244,19 @@ Starter furnace designs and the milestone list shown in the notebook.
- functions: smelterBlueprint - functions: smelterBlueprint
- variables: STARTER_DESIGNS, MILESTONES - variables: STARTER_DESIGNS, MILESTONES
### src/sim/world.js (107 lines) ### src/sim/world.js (123 lines)
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx - functions: emit, bagCap, discover, sharedArray, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, renderDirtyXY, renderDirtyChunk, renderDirtyIdx, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats - variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, renderDirty, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
### src/sim/fog.js (52 lines) ### src/sim/fog.js (52 lines)
Exploration only: never changes grains or the random stream. Exploration only: never changes grains or the random stream.
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll - functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH - variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
### src/sim/cells.js (225 lines) ### src/sim/cells.js (223 lines)
Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove).
- functions: setCell, swapCells, move, enter, enterFree, powder, passable, liquid, gas, wakeBotPowder, simMove - functions: setCell, swapCells, swapCellData, move, enter, enterFree, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL - variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
### src/sim/heat.js (266 lines) ### src/sim/heat.js (266 lines)
@@ -286,7 +293,7 @@ Travel-directed transport ends. Precomputed probes; world/plan queries allocate
- functions: pieceEnds, joinCandidate, endJoin, pieceJoins, endAt, joinIsDrop, joinSnap, nearMiss - functions: pieceEnds, joinCandidate, endJoin, pieceJoins, endAt, joinIsDrop, joinSnap, nearMiss
- variables: JOIN_ENDS, NO_PIECE - variables: JOIN_ENDS, NO_PIECE
### src/sim/conveyors.js (212 lines) ### src/sim/conveyors.js (213 lines)
How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full. How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full.
- functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, screwAccepts, straightScrewNext, diagonalScrewNext - functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, screwAccepts, straightScrewNext, diagonalScrewNext
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty - variables: CARRY_DEPTH, filterR, filterL, filtersDirty
@@ -301,7 +308,7 @@ Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SP
- functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad - functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad
- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog - variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog
### src/sim/blocks.js (235 lines) ### src/sim/blocks.js (237 lines)
Building blocks: placing, integrity, removing, lines and rectangles of blocks. Building blocks: placing, integrity, removing, lines and rectangles of blocks.
- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks - functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks
@@ -310,7 +317,7 @@ Molds: cavities for castings; ingot troughs empty themselves below. Removal retu
- functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold - functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold
- variables: DROSS, grains, barWord - variables: DROSS, grains, barWord
### src/sim/items.js (317 lines) ### src/sim/items.js (318 lines)
Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds. Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds.
- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText - functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
- variables: BAR_W, BAR_H, entCurM, droppedLast - variables: BAR_W, BAR_H, entCurM, droppedLast
@@ -325,7 +332,7 @@ The resonance drill: tuned pulses that shatter one material.
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing - functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS - variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS
### src/sim/nature.js (168 lines) ### src/sim/nature.js (170 lines)
Trees (background layer), acorns, saplings, and waking freezing water. Trees (background layer), acorns, saplings, and waking freezing water.
- functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree - functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
- variables: treeBases, saplings, treeDraw - variables: treeBases, saplings, treeDraw
@@ -362,6 +369,11 @@ Weather: clouds from steam, rain and snow, and the world water count.
- functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick - functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick
- variables: WBAND, WS, weather - variables: WBAND, WS, weather
### src/sim/workers.js (153 lines)
The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
- functions: threadWorld, threadBind, threadContext, resetChunkWork, inlineChunkWork, chunkEvent, chunkWake, chunkHeat, chunkSeed, runChunks, mergeChunkWork, chunkPasses, threadParams, threadSetParams, threadSlot, threadExecute, threadPass, threadWait, threadFailure, threadMessage
- variables: simPool, chunkWork, inlineWork
### src/sim/tick.js (29 lines) ### src/sim/tick.js (29 lines)
simTick: one simulation step, in order. simTick: one simulation step, in order.
- functions: simTick - functions: simTick
@@ -375,25 +387,21 @@ World generation helpers: noise and removal of tiny hard rock specks.
World generation: staged terrain, ore, caves and placement, with synchronous and async entry points. World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
- functions: generateWorld, generateWorldAsync - functions: generateWorld, generateWorldAsync
### src/sim/devconsole.js (113 lines) ### src/sim/devconsole.js (114 lines)
Dev commands are pure simulation operations; the browser owns the panel and confirmation. Dev commands are pure simulation operations; the browser owns the panel and confirmation.
- functions: resetDevConsole, enableDevConsole, devMaterial, devCommand - functions: resetDevConsole, enableDevConsole, devMaterial, devCommand
- variables: devWorld, devLog, devFree, devSpeed, devPaused, devSteps, DEV_HELP, DEV_MATERIALS - variables: devWorld, devLog, devFree, devSpeed, devPaused, devSteps, DEV_HELP, DEV_MATERIALS
### src/sim/save.js (183 lines) ### src/sim/save.js (185 lines)
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick - functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
- variables: SAVE_VERSION, TOFF - variables: SAVE_VERSION, TOFF
### src/render/setup.js (200 lines) ### src/render/setup.js (190 lines)
Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera. Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera.
- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont - functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont
- variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B - variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B
### src/render/light.js (103 lines)
Propagated coloured light from glowing and burning cells.
- functions: builtForLight, looseForLight, computeLight
### src/render/slope-belts.js (167 lines) ### src/render/slope-belts.js (167 lines)
Drawing only: the face stays on its simulation cells; the band hangs below it. Drawing only: the face stays on its simulation cells; the band hangs below it.
- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat - functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat
@@ -404,7 +412,69 @@ Screw casing geometry shared by both looks, ghosts and placement previews. smoot
- functions: diagonalScrewJoins, straightScrewJoins, diagonalScrewShape, diagonalScrewBlock - functions: diagonalScrewJoins, straightScrewJoins, diagonalScrewShape, diagonalScrewBlock
- variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap - variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
### src/render/world.js (404 lines) ### src/render/cell.js (276 lines)
Rare-cell shading also treats built pieces as transparent surroundings.
- functions: builtForShade, cellPixel
### src/render/objects.js (80 lines)
Loose bars, flying grains and queued blueprint blocks in the grain buffer.
- functions: worldObjects
### src/render/gpu/cracks.js (79 lines)
The drill's three seeded Perlin contour fields, uploaded once with the material tables.
- functions: gpuCrackTables
- variables: GPU_CRACKS, GPU_CRACK_COLOUR, GPU_CRACK_DRAW
### src/render/gpu/colour.js (101 lines)
The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
- functions: gpuColourShader
- variables: GPU_COLOUR, GPU_COLOUR_SMOOTH
### src/render/gpu/shaders.js (42 lines)
Half-resolution separable Gaussian bloom, then nearest colour + linear light.
- variables: GPU_BLUR, GPU_DRAW
### src/render/gpu/light.js (143 lines)
GPU sources retain the CPU roof and transmission rules; directional relaxation follows only left/up, then right/down, so it cannot introduce extra light paths.
- functions: gpuLightGrid, gpuLightBindings, gpuLight
- variables: GPU_LIGHT, GPU_LIGHT_SPREAD, GPU_LIGHT_PACK
### src/render/gpu/smooth-slope.js (107 lines)
Smooth slope geometry and cleats, ported from slope-belt-shader.js.
- variables: GPU_SMOOTH_SLOPE_HELPERS, GPU_SMOOTH_SLOPE_DRAW
### src/render/gpu/smooth-shader.js (368 lines)
Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
- variables: GPU_SMOOTH
### src/render/gpu/surface.js (5 lines)
- functions: smoothSurface
- variables: SM
### src/render/gpu/smooth.js (48 lines)
Field mip levels preserve the legacy trilinear AO sample at level 2.2.
- functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips
- variables: GPU_FIELD_MIP
### src/render/gpu/device.js (90 lines)
All pipelines compile during startup; rendering never falls back.
- functions: gpuLimitsOK, gpuInit, gpuFail, gpuRecover, gpuWarmWorld, worldPixelContext
- variables: GPU_LIMITS, GPU
### src/render/gpu/textures.js (98 lines)
Whole-world textures retain clean chunks across camera moves.
- functions: gpuTexture, gpuWrite, gpuBuffer, gpuWorldTextures, gpuViewTextures, gpuBindings, gpuUpload
### src/render/gpu/uploads.js (48 lines)
Upload full chunks so scrolling within a resident chunk needs no extra copy.
- functions: gpuUploadChunks, gpuUploadStrip, gpuCheckUploads
### src/render/gpu/draw.js (92 lines)
Only exceptional blocks and their geometric neighbours run the CPU colour rules.
- functions: gpuOverrideHas, gpuOverrides, gpuDraw, gpuPresent
- variables: GPU_PATTERN
### src/render/world.js (46 lines)
Scale colour without changing the smooth-look class. Scale colour without changing the smooth-look class.
- functions: fogColor, drawWorld - functions: fogColor, drawWorld
@@ -412,54 +482,46 @@ Scale colour without changing the smooth-look class.
Machine overlays: fans, engines, silo and the rest, drawn on top of the world. Machine overlays: fans, engines, silo and the rest, drawn on top of the world.
- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks - functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks
### src/render/overlays.js (321 lines) ### src/render/overlays.js (370 lines)
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles. Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles - functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
- variables: PING_SPEED, PING_FADE - variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
### src/render/smooth.js (99 lines)
Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
- variables: SM, smBytes
### src/render/slope-belt-shader.js (111 lines)
Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
- variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW
### src/render/smooth-shaders.js (377 lines)
Smooth shaders: vertex and fragment programs for the rounded world look.
- variables: SM_VS, SM_FS
### src/ui/audio.js (230 lines) ### src/ui/audio.js (230 lines)
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples. Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler - functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
- variables: AU, SFX, SFX_NEAR, SFX_FAR, ZONES - variables: AU, SFX, SFX_NEAR, SFX_FAR, ZONES
### src/ui/state.js (75 lines) ### src/ui/state.js (76 lines)
Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and tool status. Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and tool status.
- functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus - functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus
- variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $ - variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $
### src/ui/title.js (46 lines) ### src/ui/threads.js (39 lines)
The title screen art. Browser transport only; the simulation owns the schedule and shared state.
- functions: buildTitleCells, drawTitle - functions: startThreads
- variables: FONT5, TITLE - variables: threadStarting
### src/ui/title.js (77 lines)
Startup capabilities and title art.
- functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle
- variables: REQUIRE_WEBGPU, REQUIRE_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE
### src/ui/saves.js (224 lines) ### src/ui/saves.js (224 lines)
Saves in IndexedDB, world files (export / import) and the blueprint library. Saves in IndexedDB, world files (export / import) and the blueprint library.
- functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode - functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
- variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount - variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount
### src/ui/claims.js (145 lines) ### src/ui/claims.js (146 lines)
Shared player state; the world save remains a complete standalone fallback. Shared player state; the world save remains a complete standalone fallback.
- functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab - functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab
- variables: career, claimBusy - variables: career, claimBusy
### src/ui/session.js (158 lines) ### src/ui/session.js (162 lines)
Starting, loading and quitting a game; modal dialogs; the pause menu. Starting, loading and quitting a game; modal dialogs; the pause menu.
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds - functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
### src/ui/settings.js (125 lines) ### src/ui/settings.js (124 lines)
The settings screen. The settings screen.
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
- variables: SET_TABS, setTab - variables: SET_TABS, setTab
@@ -478,9 +540,10 @@ Notebook market: prices, orders, claim payments and wallet.
Notebook blueprints: starter designs, saved library and code sharing. Notebook blueprints: starter designs, saved library and code sharing.
- functions: renderBlueprintTab, copyCode - functions: renderBlueprintTab, copyCode
### src/ui/hud.js (34 lines) ### src/ui/hud.js (40 lines)
The tool hotbar and what is selected. The tool hotbar and what is selected.
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece - functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece
- variables: IN_BAG_ORDER
### src/ui/build-menu.js (111 lines) ### src/ui/build-menu.js (111 lines)
The build menu along the bottom: categories, piece tiles and their icons. The build menu along the bottom: categories, piece tiles and their icons.
@@ -500,7 +563,12 @@ Selection panels: current tool card and bag materials.
- functions: renderSelCard, renderBag - functions: renderSelCard, renderBag
- variables: lastBagKey - variables: lastBagKey
### src/ui/info.js (201 lines) ### src/ui/cursor.js (47 lines)
Hover work is shared by the cursor label and the vein outline.
- functions: oreLike, bitNeeded, pingVisible, cursorVein, hoveredVein, cursorLabelAlpha
- variables: CURSOR_ORES, cursorVeinCache, cursorLabelKey, cursorLabelSince
### src/ui/info.js (208 lines)
Discovery cards, hover info (I) and the cursor label. Discovery cards, hover info (I) and the cursor label.
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText - functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText
@@ -513,9 +581,9 @@ Keyboard and mouse listeners.
- functions: isTyping - functions: isTyping
- variables: canvas - variables: canvas
### src/ui/actions.js (254 lines) ### src/ui/actions.js (256 lines)
What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, firePing, toolTick - functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, pingWait, firePing, toolTick
- variables: ovShown, PING_RECHARGE - variables: ovShown, PING_RECHARGE
### src/ui/gamepad.js (60 lines) ### src/ui/gamepad.js (60 lines)
@@ -527,10 +595,9 @@ Gamepad input.
Cursor previews: shared grid helpers, air streaks and tool preview dispatch. Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
### src/ui/preview-drill.js (107 lines) ### src/ui/preview-drill.js (67 lines)
Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines run on unbroken from one cell to the next and look like real fractures, not a pattern. Drill pulse wavefronts and aiming fan. The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
- functions: crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim - functions: pulseRGB, fanPoints, drawDrillPulses, drawDrillAim
- variables: CRACK_N, CRACK_LV
### src/ui/preview-selection.js (81 lines) ### src/ui/preview-selection.js (81 lines)
Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels) Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels)
@@ -546,22 +613,32 @@ Shared world colours, minimap and full map.
- functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap - functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap
- variables: MAP_S, MAP - variables: MAP_S, MAP
### src/ui/loop.js (138 lines) ### src/ui/perf.js (15 lines)
Browser-only clocks; simulation code stays usable without a browser or a clock.
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
### src/ui/loop.js (149 lines)
The frame loop and boot. The frame loop and boot.
- functions: scanVisible, frame, boot - functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
## Tests and tools ## Tests and tools
- `test/firefox_smoke.py`: An isolated Firefox title screen and three seconds of a new pixel world.
- `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent) - `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent)
- `test/gpu-readback.js`: Read the four actual smooth textures without adding COPY_SRC usage to game resources.
- `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script. - `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
- `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness.
- `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass.
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'. - `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
- `test/run_all.py`: Run the test suites and report. - `test/run_all.py`: Run the test suites and report.
- `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports. - `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports.
- `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load. - `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
- `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning. - `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
- `test/t_caps.py`: Capability detection, title notices and the required-WebGPU startup gate.
- `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters. - `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
- `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen. - `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen.
- `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime.
- `test/t_dev_console.js`: Dev command validation, discovery, build costs, runtime controls and save metadata. - `test/t_dev_console.js`: Dev command validation, discovery, build costs, runtime controls and save metadata.
- `test/t_dev_console.py`: Dev console confirmation, keyboard isolation, HUD, time controls and saved badge. - `test/t_dev_console.py`: Dev console confirmation, keyboard isolation, HUD, time controls and saved badge.
- `test/t_diag_screws.js`: Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence. - `test/t_diag_screws.js`: Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence.
@@ -570,29 +647,40 @@ The frame loop and boot.
- `test/t_flow.js`: Throughput overlay (t49): grains per second per transport block, counted only while the overlay is on, never changing what the simulation does. - `test/t_flow.js`: Throughput overlay (t49): grains per second per transport block, counted only while the overlay is on, never changing what the simulation does.
- `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence. - `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence.
- `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player. - `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player.
- `test/t_gpu.py`: Dirty uploads, default limits, warm pipelines, world replacement and device recovery.
- `test/t_gpu_golden.py`: Frozen legacy references, captured before retirement; compare the WebGPU world only.
- `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid.
- `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables. - `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables.
- `test/t_light.js`: Run the renderer's lighting against real simulation data, with only canvas storage stubbed.
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state. - `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
- `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe. - `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
- `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling. - `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling.
- `test/t_perf.js`: Profiling fixtures start in the same state after previous scenes and warm-up ticks.
- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way. - `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way.
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore. - `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation. - `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
- `test/t_render_dirty.js`: Render invalidation must not depend on whether a grain moved or the chunk stayed awake.
- `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget. - `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget.
- `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly. - `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly.
- `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport. - `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport.
- `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls. - `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls.
- `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence. - `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence.
- `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works. - `test/t_smooth.py`: The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works.
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders. - `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
- `test/t_threads.js`: Shared allocation, checkerboard independence, and real worker-count determinism.
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds. - `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing. - `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
- `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains. - `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
- `tools/browser.py`: Shared isolated HTTP sessions; callers attach hooks before navigating to url.
- `tools/caps.py`: Print isolation, WebGPU adapter info and GL renderer for installed browsers.
- `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md. - `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md.
- `tools/perf.py`: Times the renderer and the simulation in a headless browser: light, drawWorld (with and without light and glow), the pixel upload and blit, and one simulation tick, in ms. Rebuild first. - `tools/perf-measure.js`: Temporary wrappers live only in the profiling page, never in the simulation bundle.
- `tools/perf-scenes.js`: Seeded worlds, no storage round-trip (saves quantize heat and land flying grains).
- `tools/perf.py`: Profile a seeded world on the real GPU, with isolated HTTP serving. Times are milliseconds.
- `tools/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots. - `tools/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots.
- `tools/serve.py`: Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000]. - `tools/serve.py`: Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000].
- `tools/server.py`: Serve dist/ with isolation headers; isolated_server() owns a free-port background server.
- `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript. - `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript.
- `tools/test_perf.py`: Profiler checks: headers, summaries/comparison, and unchanged pixels/simulation with clocks on.
- `tools/use_venv.py`: Let browser scripts run with plain python3 by switching to the repo's .venv. - `tools/use_venv.py`: Let browser scripts run with plain python3 by switching to the repo's .venv.
- `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from. - `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from.
- `tools/experiments/cast_sunk_mold.js`: Block-sized bit mold sunk into the ground with a stone furnace built over its funnel. - `tools/experiments/cast_sunk_mold.js`: Block-sized bit mold sunk into the ground with a stone furnace built over its funnel.
@@ -619,3 +707,5 @@ The frame loop and boot.
- `tools/scenes/t35-joins.js`: Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect. - `tools/scenes/t35-joins.js`: Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect.
- `tools/scenes/t36-funnel.js`: A twelve-block funnel, one block open at the bottom, draining 400 grains of sand. - `tools/scenes/t36-funnel.js`: A twelve-block funnel, one block open at the bottom, draining 400 grains of sand.
- `tools/scenes/t40-sloped-belt.js`: Self-contained: a flat feed climbs, crosses the top, and runs downhill; a filter sorts below. - `tools/scenes/t40-sloped-belt.js`: Self-contained: a flat feed climbs, crosses the top, and runs downhill; a filter sorts below.
- `tools/scenes/t88-light.js`: Frozen light comparisons, also used by the WebGPU browser regression suite.
- `tools/scenes/t88-renderer.js`: Paused comparison scene: ordinary grains, fog, heat, trees and exceptional geometry.
+155 -12
View File
@@ -49,7 +49,7 @@ done(); // prints ALL PASS or N FAILED, sets the
- A new suite named `test/t_<topic>.js` is picked up by `run_all.py` automatically. - A new suite named `test/t_<topic>.js` is picked up by `run_all.py` automatically.
**Determinism.** The simulation's randomness comes from seeded generators. The same code and the **Determinism.** The simulation's randomness comes from seeded generators. The same code and the
same test give the same output every run, apart from lines with timings in ms. To prove a same test give outputs identical across thread counts and between runs, apart from lines with timings in ms. To prove a
refactor changed nothing, save every suite's output before and after, and diff them: refactor changed nothing, save every suite's output before and after, and diff them:
```sh ```sh
@@ -58,6 +58,17 @@ for t in test/t_*.js; do node $t > /tmp/after/$(basename $t).txt; done # afte
diff -r /tmp/before /tmp/after diff -r /tmp/before /tmp/after
``` ```
`test/t_threads.js` runs the real shared-memory scheduler inline and with 1, 2 and 8
Node workers, including repeats, comparing all mutable cell/chunk bytes and JS side effects.
The browser creates up to `min(16, hardwareConcurrency - 2, CPU workers setting)` helpers.
Small passes run inline; larger passes use batches to amortize dispatch. Wakes and heat flags
merge after each pass; long liquid searches are deferred until that merge. Global machine and
weather randomness remains separate from the chunk streams. World generation uses the same
inline schedule while settling. The t88 re-baseline changes worldgen material hashes and the
seeded sand pile bounds in `t_corner_seal.js`; the heat oracle now supports pass outputs.
The conveyor fixture uses a wider feed pile, the screen fixture warms longer, and `t_load.js`
checks waking within eight ticks separately from the probabilistic consumption of fuel.
## Browser tests ## Browser tests
- They need the `.venv` from README.md → Develop; plain `python3` switches to it on its own. - They need the `.venv` from README.md → Develop; plain `python3` switches to it on its own.
@@ -67,12 +78,47 @@ diff -r /tmp/before /tmp/after
gamepad and world sizes. Screenshots go to gamepad and world sizes. Screenshots go to
`shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth `shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth
look. look.
- `test/t_smooth.py` checks that the WebGL2 smooth look compiles and draws, that it falls back - `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens.
to pixels cleanly, and that the thermal lens works. - `test/t_gpu_golden.py` compares 28 cropped legacy reference PNGs (384x288, under
- Run them one at a time. Two headless browsers at once can starve each other, and `test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes,
`t_smooth.py`'s half-resolution check then fails. `run_all.py` runs them one after the other. both looks at zoom 12 and 2. ImageMagick decodes RGB; the per-channel tolerance is 8,
- Rebuild first (`python3 build.py`). They load `dist/index.html`. `run_all.py --browser` mean RGB error must stay below 0.5/255 and fewer than 0.3% of pixels may exceed 8
rebuilds for you. (smooth AO/edge rounding). These are frozen pre-removal legacy images, not generated
from the new renderer. The compressed `golden-world-640x*.json.gz` fixtures retain the
Phase 2 world bytes that produced them, isolating renderer checks from worldgen RNG changes. Inspect the matching `shots/golden-*.png` after visible changes.
- `test/t_gpu.py` checks default limits (four storage textures per stage), dirty-upload
readback after 240 busy ticks with scrolling and both looks, exact dirty/full image equality
with a frozen clock, world replacement, transparent overlays, drill contours, shader compilation timing, successful device recreation and failed recovery with
an autosave and Reload. `gpuCheckUploads()` reads actual resident GPU texture bytes and
compares them to the full CPU arrays, including Float32 temperatures, machine and block data.
- `test/t_render_dirty.js` checks invalidation of sleeping chunks, heat edges, swaps, fog and trees.
- The old CPU light checks now run as `test/t_gpu_light.py` / `test/light-cases.js`, reading the
actual WGSL float light grid. Transmission tolerance is 0.001 because the GPU work textures
store float16 (the removed CPU implementation used float32). Behavioural expectations remain.
- Browser suites run one at a time; concurrent GPU work distorts timing and can starve tests.
- Rebuild first (`python3 build.py`); `run_all.py --browser` rebuilds for you. All browser tools
use `tools/browser.py` and `tools/server.py`: a free-port background HTTP server serves the
built page with COOP/COEP headers and shuts down after the browser. No `file://` loads.
- Choose Chromium graphics with `--gpu real|soft` or `DEEPGRAIN_GPU=real|soft` (the command line
wins). This works for the suite runner, individual browser tests, screenshots and profiler.
Real mode uses ANGLE Vulkan on the host GPU; soft uses SwiftShader.
Unsupported software adapters show the same startup notice. Firefox uses its own default graphics backend.
The default is **real**, including the profiler and `caps.py`. No colour tolerances were
changed. The play test holds the slow-save fixture open during its screenshot, waits for
tool/selection UI updates, disables camera lead after testing it, and gives the gamepad drill
a known reachable target; these checks do not depend on GPU speed or earlier digging.
- `python3 tools/caps.py` prints isolation, actual WebGPU adapter info and the GL renderer for
Chromium and installed Firefox. Real mode requires a hardware Chromium adapter.
- `test/t_caps.py` checks absent, null, rejected and insufficient-limit adapters, notices and
the mandatory WebGPU startup gate. Cross-origin isolation is required for threads. Firefox smoke
checks that a browser without WebGPU shows the notice and cannot start a world.
- Install Firefox with `.venv/bin/python -m playwright install firefox`.
`python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world
only when WebGPU is available, and otherwise verifies the startup block. `run_all.py --browser` includes it and prints a
skip note if Firefox is absent; an installed browser that fails to launch is a failure.
- Linux WebGPU currently needs Firefox Nightly; the notice points to `about:config` →
`dom.webgpu.enabled`. Sources: [Mozilla's experimental features](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features#webgpu_api)
and [GPUWeb implementation status](https://github.com/gpuweb/gpuweb/wiki/Implementation-Status#firefox).
## Screenshots: tools/shot.py ## Screenshots: tools/shot.py
@@ -88,6 +134,13 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
`tools/scenes/clear-sky.js` clears a flat work area and sets `FLOOR`, `BX`, `BY` and `CAM`. Set `tools/scenes/clear-sky.js` clears a flat work area and sets `FLOOR`, `BX`, `BY` and `CAM`. Set
`CAM = [x, y]` to frame the shot. `CAM = [x, y]` to frame the shot.
- The HUD and the cursor previews are hidden unless you pass `--hud` or `--previews`. - The HUD and the cursor previews are hidden unless you pass `--hud` or `--previews`.
- Both looks require WebGPU; there is no renderer switch or legacy fallback.
- `test/t_gpu.py` checks GPU uploads, dirty/full equality, contours, resize, timing and device loss.
World-pixel tests use `worldPixelContext()` to read both stacked canvases. The helper
presents the retained GPU result again before copying, because canvas textures expire after
presentation ([WebGPU specification](https://gpuweb.github.io/gpuweb/#abstract-opdef-expire-the-current-texture),
[Mozilla implementation notes](https://bugzilla.mozilla.org/show_bug.cgi?id=1965377)). Tests that change
static `noiseB` in place set `GPU.world = null` before drawing to upload their new fixture.
- `--look smooth` for the smooth look. Test in the pixel look unless you are checking graphics. - `--look smooth` for the smooth look. Test in the pixel look unless you are checking graphics.
- `--freeze` draws the last frame at a fixed time and frame count, so animations are caught at - `--freeze` draws the last frame at a fixed time and frame count, so animations are caught at
the same moment. Two shots of the same scene then match pixel for pixel, and a before / after the same moment. Two shots of the same scene then match pixel for pixel, and a before / after
@@ -106,10 +159,9 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
## Pitfalls ## Pitfalls
- **WebGL in headless Chromium.** There is no GPU, so the smooth look needs SwiftShader: - **GPU access in headless Chromium.** Real mode needs access to GPU devices and driver caches;
launch with `--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`. Also set a filesystem sandbox can silently force software rendering. Use `tools/caps.py` to check.
`window.__allowSoftwareGL = true` before the page loads (`add_init_script`); otherwise the game Soft mode is available for machines without a GPU. Both modes load isolated HTTP pages.
sees software rendering and switches to the pixel look. `tools/shot.py` does both.
- **Top-level `const` and `let` are not `window` properties.** In the page, `window.SM` is - **Top-level `const` and `let` are not `window` properties.** In the page, `window.SM` is
`undefined` even though `SM` exists. Write `typeof SM !== 'undefined'`, or use the bare name. `undefined` even though `SM` exists. Write `typeof SM !== 'undefined'`, or use the bare name.
Functions declared with `function` are on `window`. Functions declared with `function` are on `window`.
@@ -128,7 +180,98 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
## Measuring ## Measuring
- `tools/perf.py`: renderer and tick times in the browser. - `tools/perf.py`: the phase 0 CPU/frame profiler, served over HTTP with COOP/COEP isolation.
Rebuilds automatically. Run on an otherwise idle machine, without another browser test:
```sh
python3 tools/perf.py --json tools/perf-baseline.json
python3 tools/perf.py --json /tmp/perf-now.json --compare tools/perf-baseline.json
python3 tools/perf.py --viewport 1920x1080 --world 2048x1024 --zooms 12,6,4,3,2 \
--looks pixel,smooth --scenes settled,factory,heat --seconds 10 --json /tmp/perf.json
python3 tools/test_perf.py
```
The default matrix is three scenes × two looks × five zooms × lighting full/off. The world
seed is 424242; fog is off so all visible grains are shaded. Settings otherwise use game
defaults (including glow and the minimap), with autosave, camera lead and shake disabled.
`settled` runs 600 ticks; `factory` reuses the furnace/transport layout from
`experiments/factory_cost.js`, adding screws, a sand filter and full silos for sustained sand
and ore flow; `heat` places water over magma beside a rock floor. The latter two settle for
another 60 ticks. Each rebuild resets the power-rebuild scheduler, weather random stream and all statistics to
fresh-page values; fixture hashes are checked by `test/t_perf.js`. No save/load snapshot is used: it would quantize heat and land flyers.
**World** is the whole `drawWorld` CPU call. **Prep** sets up the view/masks/joins;
**Colour** fills CPU rare-cell overrides (including ghosts and flyers); **Upload** writes
dirty world chunks and override textures; **Blit** submits GPU drawing and composition.
These are CPU wall times, including driver stalls; they are not GPU timestamp measurements.
Bloom is computed on the GPU. GPU light timestamps are recorded separately as `gpuLight`.
Overlay totals and each individual draw call are in JSON, with ms/frame and invocation counts; conditional
overlays absent from the fixture have zero calls, not a measured zero execution cost.
Five calls warm each render configuration before 30 measured calls. Simulation warms five
full 120-tick periods on a disposable scene, then rebuilds the same seeded fixture and
measures 240 consecutive ticks. Every direct `simTick` callee is discovered from its source;
JSON records per-call and amortized per-tick costs and invocation counts, including periodic
functions. Inactive paths (e.g. load wake, saplings, flow tracking) have zero calls and null
per-call cost. Unattributed time includes tick bookkeeping and clock/wrapper overhead.
Awake/hot chunk counts are recorded at the start/end of simulation and real runs.
Real-loop tests independently rebuild each scene for zooms 2 and 6 in both requested looks,
regardless of `--zooms`. `--seconds` applies to each of these twelve default runs. Raw samples
retain frame interval (rAF timestamps, including waiting/scheduling), CPU frame work, total
simulation work per frame and number of ticks. p50/p95/p99 use interpolated sorted samples;
five warm-up frames are excluded. Zero-tick frames correctly have zero simulation time.
JSON records a fixed render scale of 1 for both looks.
Comparisons accept subsets of the baseline scenes, looks and zooms, with identical world/viewport dimensions and run duration, print all timing changes, and mark >10% regressions;
tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change.
Performance runs launch Chromium with
`--use-angle=vulkan --enable-features=Vulkan,VulkanFromANGLE --enable-gpu --ignore-gpu-blocklist --enable-gpu-rasterization --enable-unsafe-webgpu`.
They record the unmasked GPU/renderer, cores, browser version, viewport/world size,
`crossOriginIsolated`, `navigator.gpu`, source hash and elapsed time. Real mode refuses software
renderers; both modes require isolation. Chromium needs access to the host GPU devices; a filesystem
sandbox that hides `/dev/dri`/NVIDIA devices must be lifted for this measurement. The browser
tests' SwiftShader numbers (and explicit `--gpu soft` profiler runs) are not a hardware performance baseline.
The existing in-game Performance stats toggle shows smoothed CPU world/light/overlay times
and movement/heat/rest per simulation tick. Detailed renderer clocks are off by default
(`PERF_MARKS = false`); the raw frame hook (`PERF_FRAME`) is also off outside the profiler.
`tools/server.py` shares the isolation/no-cache headers with `tools/serve.py`, and supplies a
free-port background server with cleanup for measurement tools.
- `tools/experiments/factory_cost.js`: what running factories cost the simulation. - `tools/experiments/factory_cost.js`: what running factories cost the simulation.
- `tools/experiments/flow.js`: belt and screw conveyor throughput in grains a second. - `tools/experiments/flow.js`: belt and screw conveyor throughput in grains a second.
- `tools/experiments/worldgen.js`: material counts and timings of world generation. - `tools/experiments/worldgen.js`: material counts and timings of world generation.
Both looks use WebGPU. Colour measures the CPU rare-cell override fill, including loose
objects and ghosts. `frame_ms` is the complete paused-frame CPU call, with overlays and UI.
Upload measures dirty world chunks, override buffers and small lighting tables; Blit measures
GPU command submission. `override_cells` counts CPU cells. GPU timestamp samples include
colour, lighting, bloom and drawing, excluding uploads. CPU lighting is gone. The GPU canvas
draws the world; the transparent game canvas holds overlays. Override colours and glow live in
plain typed arrays, without offscreen Canvas2D backing stores.
Use `tools/scenes/t88-renderer.js` with `--freeze --zoom 12` (or 6/2),
`--viewport 1920x1080` and `--look pixel|smooth`. It includes terrain, water, fire, magma,
fog, a tree, flat/slope belts, screws, pipe, mold, bar and blueprint ghost. Frozen legacy
comparisons are in `test/t_gpu_golden.py`; no legacy renderer remains in the game.
The smooth shader retains the nine-tap glow filter and samples field AO at mip level 2.2;
three render passes generate field levels 1-3, including non-power-of-two view sizes.
Only smooth uploads back/tree/field overrides and a separate presence mask. Profiler scale is
always 1. The screenshot tool invalidates the parallax horizon after setup changes `surfY`.
Mip pass references: [Chrome's porting guide](https://developer.chrome.com/docs/web-platform/webgpu/from-webgl-to-webgpu#mipmap_generation)
and [WebGPU Fundamentals](https://webgpufundamentals.org/webgpu/lessons/webgpu-importing-textures.html#generating-mips-on-the-gpu).
`perfSim` reports synchronous tick wall time (also occupied main-thread time), polling in
`threadWait`, and main compute excluding polling. The latter includes serial simulation,
dispatch/merge and the main thread's own chunk batch; it is not pure scheduling overhead.
Final t88 full-matrix results are saved in `tools/perf-after-t88.json`; keep
`tools/perf-baseline.json` unchanged as the original comparison.
The required pipeline limits are checked in `gpuLimitsOK` before requesting the device: four
storage textures, sixteen sampled textures and eight storage buffers per stage, plus 8192 for
2D textures; no non-default limit is requested. References: [WebGPU limits](https://www.w3.org/TR/2026/CRD-webgpu-20260109/#limits)
and [Chromium Dawn defaults](https://dawn.googlesource.com/dawn.git/+/refs/heads/chromium/7945/src/dawn/native/Limits.cpp).
All game pipelines use async creation during capability checking. World loading submits both
looks before `GAME.running` becomes true. Shader and loading timings are printed by `t_gpu.py`;
look/zoom switches must not call synchronous pipeline creation.
+583
View File
@@ -4,6 +4,589 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-28: t88 phase 4 hardware verification and handoff (Codex)
- Reviewed the existing uncommitted Phase 4 changes rather than duplicating the implementation.
Grepped src/test/tools/docs for retired R canvas fields, CPU-light profiler fields, renderer
switches, smoothPace and thread callers. Retained cell.js/objects.js and test light oracles.
Cleanup against committed Phase 3: 23 code lines removed, 14 added (net -9). No further
source deletion or simulation change. TESTING now also correctly describes fixed render scale 1.
- Fresh NVIDIA WebGPU play-through passed: 122.779 s, 7,355 ticks (59.91/s), 16 helpers,
peak active 4. Flew, drilled 63 grains, vacuumed 27 sand, mouse-built 11 belts and 8 screws,
fed sand repeatedly and ran water/magma. No page/console errors, pause or stopped simulation.
Opened drill/flow/final screenshots: bot/terrain/UI intact, sand reaches the outlet pile,
water remains above cooled magma. Evidence: shots/t88-phase4/result.json and PNGs;
/tmp/t88-live.py and /tmp/t88-phase4-live-current.txt. Sandbox adapter assertion failed
before gameplay; host-GPU retry passed.
- Fresh full default profile exited 0 in 215.99 s: tools/perf.py --compare
tools/perf-baseline.json --json tools/perf-after-t88.json. NVIDIA Blackwell, Chromium 156,
24 cores, 1920x1080 / 2048x1024, 30 views with full/off lighting and 12 live runs.
Source fingerprint verified; original baseline byte-equal to HEAD. Settled/factory/heat
1.806/2.393/2.623 ms/tick (2.06x/1.53x/1.88x baseline); polling 0.299/0.393/0.433,
main compute excluding polling 1.507/2.000/2.190 ms/tick. All live runs hold 60 fps;
zero CPU work durations and zero frame intervals over 16.7 ms in 7,137 samples.
CPU frame p50/p95, ms (zoom 2 / zoom 6):
| Scene | Pixel | Smooth |
|---|---|---|
| settled | 7.09/8.31 / 2.78/3.93 | 8.43/9.75 / 3.00/4.19 |
| factory | 8.43/9.80 / 4.92/6.14 | 10.16/11.45 / 4.90/5.92 |
| heat | 8.52/9.80 / 4.92/5.95 | 9.99/11.08 / 5.25/6.48 |
- Per-phase gains, factory/pixel/zoom 2, ms; history from the original t88 entries below:
| Metric | Baseline | After 2a | After 2b | After 2d | After 3 | Final 4 |
|---|---:|---:|---:|---:|---:|---:|
| drawWorld | 24.55 | 9.98 | 6.79 | 2.53 | 2.44 | 2.48 |
| CPU frame p50 | 31.70 | 15.40 | 10.25 | 9.28 | 8.48 | 8.43 |
| sim/tick | 3.657 | unrecorded | unrecorded | 4.200 | 2.495 | 2.393 |
Final factory drawWorld 9.90x, CPU frame p50 3.76x faster than baseline. Smooth factory
zoom-2 CPU p50: 34.71 baseline -> 14.5 after 2c -> 10.60 after 2d -> 9.59 after 3 ->
10.16 final; earlier 2a/2b smooth numbers unrecorded. Phase 4 differences from Phase 3
are run variation; no Phase 4 speedup claimed.
- Polling retained: threadPass already overlaps its local batch with helpers; following passes
and serial work require completed outputs. waitAsync is available, but safe yielding reaches
chunkPasses, simMove/heatPass, simTick/frame and input/save/render access to shared state.
No cheap safe overlap or measured gain established. Semantics rechecked against
[MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/waitAsync)
and [ECMAScript](https://tc39.es/ecma262/multipage/structured-data.html#sec-atomics.waitasync).
- Recommendation: YES to a separate whole-simulation-worker project; not implemented.
Fresh auxiliary /tmp/t88-orchestration.py runs (factory/pixel/2 and heat/smooth/2,
599 ticks each) measure dispatch/decode/merge 0.564/0.584 ms/tick, 0.571/0.589 ms/frame;
local batch 1.140/1.307 and polling 0.492/0.561 ms/tick. These wrapped measurements are
separate from the unwrapped matrix. Live simulation p50 occupies 2.10-2.66 ms/frame;
expected main-thread recovery about 2-3 ms before messaging/snapshot costs. No expected fps
gain in already-60-fps scenes. Worst zoom-2 CPU p95 11.448 leaves 5.218 ms of 16.667;
worst zoom-2 p99 12.384 leaves 4.283 ms. Benefit is responsiveness and larger-factory headroom.
Cost: messages for flight/drill/vacuum/build/interact, undo/blueprints/dev console; worker
ownership of generation/load/save/export/pause/step; coherent published state for GPU and
rare-cell rendering, HUD/bag/economy, hover/minimap; helper-pool integration. Rendering must
never read in-flight writes. Deterministic commands, input latency and save consistency
need separate tests.
- PROJECT_MAP regenerated. tools/test_perf.py exited 0 (headers, quantiles/comparison,
deterministic state and both-look pixel parity); both readout screenshots inspected.
Final python3 test/run_all.py --browser exited 0, ALL PASSED: all 32 headless suites;
gameplay 361, smooth 28, console 18, capabilities 7, GPU 12, golden 28, light 71,
Firefox 2 checks. Evidence: /tmp/t88-phase4-final-current.txt. Suite summaries match the
prior gate apart from timings; no source changes during verification. Final source
fingerprint/baseline checks, diff check and kandown doctor passed; task remains Review.
Files: retained render/{setup,cell,world,gpu/draw,gpu/textures}, tools/{perf.py,
perf-measure.js,perf-after-t88.json}, TESTING/PROJECT_MAP/agent-log and t88 tracking.
No commit; DESIGN and owner's Context untouched. Owner visual review remains.
## 2026-10-28: t88 phase 4 requested verification refresh (Codex)
- Reviewed existing uncommitted Phase 4 work. Whole-tree src/test/tools/docs searches confirm
removed Canvas2D backing stores and CPU-light profiler fields have no consumers. Kept
cell.js/objects.js, GPU lighting fields, live settings and test helpers. No further safe
deletion found. Cleanup versus committed Phase 3: 23 code lines removed, 14 added, net -9.
No additional source or simulation behaviour changes in this refresh.
- Fresh NVIDIA WebGPU live check: 122.800 seconds, 7,356 ticks (59.90/s), 16 helpers,
peak active 4. Flew, drilled 63 grains, vacuumed 27 sand, mouse-built 11 belts and 8 screws,
repeatedly fed sand and ran water/magma. No page/console errors, pause or stopped simulation.
Inspected drill, flow and final screenshots: intact bot/terrain/UI, sand piles beyond the
outlet and water/magma cooling. Evidence: shots/t88-phase4/result.json and PNGs;
/tmp/t88-live.py and /tmp/t88-phase4-live-refresh.txt. Sandbox GPU assertion failed before
gameplay; host-GPU retry passed.
- Fresh full default profile: `python3 tools/perf.py --compare tools/perf-baseline.json
--json tools/perf-after-t88.json`, exit 0, 216.5 s. NVIDIA Blackwell, Chromium 156,
24 cores, 1920x1080 / 2048x1024, all 30 views with full/off light and 12 live runs.
Saved source fingerprint verified; original baseline byte-equal to HEAD.
Settled/factory/heat: 2.064/2.439/2.565 ms/tick (1.80x/1.50x/1.92x baseline).
Polling 0.368/0.431/0.440; compute excluding polling 1.696/2.008/2.125 ms/tick.
All live runs hold 60 fps; 0/7,138 CPU frames or intervals exceed 16.7 ms.
CPU frame p50/p95, ms (zoom 2 / zoom 6):
| Scene | Pixel | Smooth |
|---|---|---|
| settled | 7.11/8.43 / 2.77/3.93 | 8.51/9.95 / 3.10/4.26 |
| factory | 8.33/9.70 / 5.07/5.93 | 10.26/11.49 / 5.02/6.17 |
| heat | 8.57/9.82 / 4.91/5.87 | 10.05/11.17 / 5.02/6.02 |
- Phase gains, factory/pixel/zoom 2, ms; historical columns from earlier t88 entries:
| Metric | Baseline | After 2a | After 2b | After 2d | After 3 | Final 4 |
|---|---:|---:|---:|---:|---:|---:|
| drawWorld | 24.55 | 9.98 | 6.79 | 2.53 | 2.44 | 2.30 |
| CPU frame p50 | 31.70 | 15.40 | 10.25 | 9.28 | 8.48 | 8.33 |
| sim/tick | 3.657 | unrecorded | unrecorded | 4.200 | 2.495 | 2.439 |
Final factory drawWorld 10.68x and CPU frame p50 3.81x faster than baseline. Smooth factory
zoom-2 CPU p50: 34.71 baseline -> 14.5 after 2c -> 10.60 after 2d -> 9.59 after 3 ->
10.26 final; earlier smooth 2a/2b figures unrecorded. Phase 4 timing differences are run
variation; no Phase 4 speedup over 3 claimed.
- Polling retained: main-thread chunk work already overlaps helpers; later passes and serial
work need completed outputs. waitAsync is present, but safely yielding requires async
propagation through chunkPasses, simMove/heatPass, simTick/frame and guards for input,
saves and rendering during in-flight shared writes. No cheap safe overlap or measured gain
established. Semantics checked against [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/waitAsync)
and [ECMAScript](https://tc39.es/ecma262/multipage/structured-data.html#sec-atomics.waitasync).
- Recommendation: YES to a separate whole-simulation-worker project; not implemented.
Fresh auxiliary /tmp/t88-orchestration.py runs (factory/pixel/2 and heat/smooth/2, 599 ticks
each) measured dispatch/decode/merge 0.558/0.561 ms/tick, local batch 1.086/1.188,
polling 0.489/0.488. Orchestration is 0.565 ms per sampled frame in both runs. These wrapped
measurements are separate from the unwrapped matrix. Final live simulation occupies
2.09-2.64 ms/frame at p50. Expected main-thread recovery about 2-3 ms before messaging and
snapshot costs; no expected fps gain in these already-60-fps scenes. Worst zoom-2 CPU p95
11.495 leaves 5.172 ms of 16.667; p99 12.692 leaves 3.975 ms. Benefit: responsiveness
and room for larger factories. Cost: command messages for flight/drill/vacuum/build/interact,
undo/blueprints/dev console; worker ownership of generation/load/save/export/pause/step;
coherent published state for GPU/rare-cell rendering, HUD/bag/economy, hover and minimap;
helper-pool integration. Rendering must never read a world mid-write. Deterministic command
order, input latency and save consistency need separate tests.
- PROJECT_MAP regenerated; existing TESTING updates cover retired renderer/tool fields.
tools/test_perf.py exited 0: headers, quantiles/comparison, deterministic state and both-look
pixel parity. Both readout screenshots inspected. Final `python3 test/run_all.py --browser`
exited 0, ALL PASSED: all 32 headless suites, gameplay 361, smooth 28, console 18, capabilities
7, GPU 12, golden 28, light 71 and Firefox 2 checks. Output:
/tmp/t88-phase4-requested-tests.txt. Source unchanged during verification; original baseline
and DESIGN verified unchanged. Final map/diff/board checks complete; t88 remains Review.
Files: retained render/{setup,cell,world,gpu/draw,gpu/textures}, tools/{perf.py,
perf-measure.js,perf-after-t88.json}, TESTING/PROJECT_MAP/agent-log and t88 tracking.
No commit; DESIGN and owner's Context untouched. Owner review remains.
## 2026-10-28: t88 phase 4 current-tree completion check (Codex)
- Reviewed the existing uncommitted Phase 4 implementation. Whole-tree src/test/tools/docs
searches cover removed Canvas2D fields, CPU light/colour names, legacy switches, light-grid
fields and settings; no further unused renderer functions found. Kept cell.js/objects.js,
GPU lighting fields and test oracles. Cleanup versus committed Phase 3: 23 code lines
removed, 14 added, net -9. No additional source change or simulation behaviour change.
- Fresh real NVIDIA WebGPU live check: /tmp/t88-live.py, 122.827 seconds / 7,357 ticks
(59.90/s), 16 helpers, peak active 4. Flew, drilled 63 grains, vacuumed 27 sand,
mouse-built 11 belts and 8 screws, repeatedly fed sand and ran water/magma. No page/console
errors, paused/stopped simulation or abnormal stuck behaviour found. Opened drill, flow and
final screenshots: bot/terrain/UI intact, sand crosses the line and piles beyond its outlet,
water/magma reacts and cools. Evidence: shots/t88-phase4/result.json and PNGs. Sandbox
adapter assertion failed before gameplay; host-GPU retry passed.
- Fresh full default `python3 tools/perf.py --compare tools/perf-baseline.json --json
tools/perf-after-t88.json` exited 0 in 217.0 s. NVIDIA Blackwell (owner's RTX 5070),
Chromium 156, 24 cores, 1920x1080 / 2048x1024; all 30 views with full/off light,
12 live runs. Saved source fingerprint matches; original baseline byte-equal to HEAD.
Settled/factory/heat: 1.828/2.433/2.667 ms/tick (2.03x/1.50x/1.85x baseline).
Polling 0.318/0.417/0.430; main compute excluding polling 1.510/2.016/2.237 ms/tick.
All live runs hold 60 fps; 0/7,140 CPU frames or intervals exceed 16.7 ms.
CPU frame p50/p95, ms (zoom 2 / zoom 6):
| Scene | Pixel | Smooth |
|---|---|---|
| settled | 7.11/8.49 / 2.84/4.06 | 8.45/9.98 / 2.94/4.07 |
| factory | 8.46/9.71 / 4.91/6.00 | 9.54/11.04 / 4.88/5.98 |
| heat | 8.55/10.14 / 4.95/5.97 | 9.93/11.13 / 5.41/6.65 |
- Phase history, factory/pixel/zoom 2, ms; historical numbers from earlier t88 log entries:
| Metric | Baseline | After 2a | After 2b | After 2d | After 3 | Final 4 |
|---|---:|---:|---:|---:|---:|---:|
| drawWorld | 24.55 | 9.98 | 6.79 | 2.53 | 2.44 | 2.29 |
| CPU frame p50 | 31.70 | 15.40 | 10.25 | 9.28 | 8.48 | 8.46 |
| sim/tick | 3.657 | unrecorded | unrecorded | 4.200 | 2.495 | 2.433 |
Final factory drawWorld 10.72x and CPU p50 3.75x faster than baseline. Smooth factory
zoom-2 CPU p50: 34.71 baseline -> 14.5 after 2c -> 10.60 after 2d -> 9.59 after 3 ->
9.54 final (earlier smooth 2a/2b figures unrecorded). Phase 4 timing differences are run
variation; no claimed Phase 4 speedup over 3.
- Polling retained. The main thread already overlaps its batch with helpers; the next pass
and serial work need completed outputs. waitAsync exists in this Chrome, but a safe use
propagates through chunkPasses, simMove/heatPass, simTick/frame and guards input/save/render
from in-flight shared writes. No cheap safe overlap or measured gain established.
API behaviour verified against [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/waitAsync)
and [ECMAScript](https://tc39.es/ecma262/multipage/structured-data.html#sec-atomics.waitasync).
- Recommendation: YES to a separate whole-simulation-worker project; not implemented.
Fresh auxiliary /tmp/t88-orchestration.py runs (factory/pixel/2 and heat/smooth/2, 599 ticks
each) measured dispatch/decode/merge at 0.565/0.587 ms/tick, local batch 1.099/1.316,
polling 0.485/0.543. Dispatch/decode/merge is 0.573/0.592 ms per sampled frame.
These wrapped measurements are separate from the unwrapped matrix.
Final live simulation occupies 2.09-2.74 ms/frame at p50; moving ownership could free
about 2-3 ms of main-thread time before message/snapshot costs. No fps gain expected on
these already-60-fps scenes. Worst zoom-2 CPU p95 11.127 leaves 5.540 ms of 16.667;
p99 12.614 leaves 4.052 ms. Benefit: responsiveness and headroom as factories grow.
Cost: command messages for flight/drill/vacuum/build/interact, undo/blueprints/dev console;
worker ownership of generation/load/save/export/pause/step; coherent published state for
GPU/rare-cell rendering, HUD/bag/economy, hover/minimap; integration with the helper pool.
Rendering must never read a world mid-write. Command determinism, input latency and save
consistency need separate tests.
- Map regenerated; TESTING already documents the tool cleanup. Profiler checks passed (headers, quantiles, comparison, deterministic simulation,
pixel/smooth image parity); both readout screenshots inspected. Final
`python3 test/run_all.py --browser` exited 0, ALL PASSED: 32 headless suites, gameplay
361, smooth 28, console 18, capabilities 7, GPU 12, golden 28, light 71, Firefox 2 checks.
Output: /tmp/t88-phase4-tests-current.txt. Suite summaries match the prior refresh except
timings. Map, diff check and kandown doctor passed. Phase 4 complete; t88 remains Review.
Files: retained render/{setup,cell,world,gpu/draw,gpu/textures}, tools/{perf.py,
perf-measure.js,perf-after-t88.json}, TESTING/PROJECT_MAP/agent-log and t88 tracking.
No commit; DESIGN and owner's Context untouched. Owner review remains.
## 2026-10-28: t88 phase 4 fresh live run, profile and review (Codex)
- Found the earlier uncommitted Phase 4 work and reviewed it rather than adding another
implementation. Whole-tree src/test/tools/docs searches confirm removed Canvas2D backing
stores and CPU-light profiler fields have no remaining live consumers. Retained cell.js,
objects.js, GPU lighting grid, settings and test light oracle. Cleanup versus 7468d00 is
23 code lines removed, 14 added, net -9; no simulation changes or further source edits.
- Rebuilt and repeated the real-GPU live check with /tmp/t88-live.py: 122.807 seconds,
7,356 ticks (59.90/s), 16 helpers, active peak 4, no page/console errors, pause or stopped
simulation. Flew with keys, drilled 63 grains, vacuumed 27 sand, mouse-built 11 belts and
8 screws, repeatedly fed sand alongside water/magma. Inspected all four screenshots under
shots/t88-phase4/: intact bot/terrain/UI, sand crosses the line and piles beyond the outlet,
water/magma reacts and cools. No abnormal stuck behavior found. Sandbox GPU assertion
failed before play began; host-GPU retry passed.
- Fresh full default `python3 tools/perf.py --compare tools/perf-baseline.json --json
tools/perf-after-t88.json` exited 0 in 216.0 seconds. NVIDIA Blackwell / RTX 5070 setup,
Chromium 156, 24 cores, 1920x1080 / 2048x1024. All 30 views, full/off lighting and 12 live
runs; source fingerprint verified and original baseline byte-equal to HEAD.
Settled/factory/heat sim: 1.983/2.442/2.506 ms/tick, 1.87x/1.50x/1.97x baseline.
Polling: 0.376/0.404/0.396; compute excluding polling: 1.607/2.038/2.110 ms/tick.
Every live run holds 60 fps; 0/7,139 sampled CPU work durations or frame intervals >16.7 ms.
CPU frame p50/p95, ms (zoom 2 / zoom 6):
| Scene | Pixel | Smooth |
|---|---|---|
| settled | 7.04/8.27 / 2.93/4.12 | 8.42/9.79 / 2.99/4.09 |
| factory | 8.50/9.86 / 4.88/6.13 | 9.58/10.88 / 4.91/6.05 |
| heat | 8.46/9.81 / 4.80/5.92 | 9.90/11.01 / 5.06/6.18 |
- Phase history, factory/pixel/zoom 2, ms; historical columns from the entries below:
| Metric | Baseline | After 2a | After 2b | After 2d | After 3 | Final 4 |
|---|---:|---:|---:|---:|---:|---:|
| drawWorld | 24.55 | 9.98 | 6.79 | 2.53 | 2.44 | 2.48 |
| CPU frame p50 | 31.70 | 15.40 | 10.25 | 9.28 | 8.48 | 8.50 |
| sim/tick | 3.657 | unrecorded | unrecorded | 4.200 | 2.495 | 2.442 |
Final factory drawWorld 9.91x and CPU p50 3.73x faster than baseline. Smooth factory
zoom-2 CPU p50: 34.71 baseline -> 14.5 after 2c -> 10.60 after 2d -> 9.59 after 3 ->
9.58 final (2a/2b smooth not recorded). No claimed Phase 4 speedup; variation from Phase 3.
- Polling retained after tracing threadPass/threadWait and their callers. Main-thread chunk
work already overlaps helpers; later passes/serial work need completed outputs. A safe
waitAsync change reaches chunkPasses, simMove/heatPass, simTick/frame and UI/save/render
access to in-flight arrays. No cheap safe overlap or measured gain established. API semantics
rechecked against [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/waitAsync)
and [ECMAScript](https://tc39.es/ecma262/multipage/structured-data.html#sec-atomics.waitasync).
- Recommendation: YES, as a separate whole-simulation-worker project; not implemented.
Fresh auxiliary /tmp/t88-orchestration.py runs (factory/pixel/2 and heat/smooth/2, 599 ticks
each) measure dispatch/decode/merge at 0.56-0.58 ms/tick, local batch 1.15-1.30, polling
0.49-0.55. These wrapped runs are separate from the unwrapped final profile above.
Final live sim p50 occupies 2.13-2.63 ms/frame. Expected main-thread recovery about 2-3 ms
before message/snapshot costs; no expected fps increase in these already-60-fps scenes.
Worst zoom-2 CPU p95 11.014 leaves 5.653 ms of 16.667; p99 12.103 leaves 4.564 ms.
Cost: command messages for flight/drill/vacuum/build/interact, undo/blueprints/dev console;
worker ownership of generation/load/save/export/pause/step; coherent GPU/rare-cell rendering,
HUD/bag/economy, hover and minimap snapshots; integration with the helper pool. Deterministic
command order, input latency and save/snapshot consistency need their own verification.
- Verification refresh complete: tools/test_perf.py exited 0 (headers, summaries, determinism
and both-look pixel parity); both readout screenshots inspected. Final
`python3 test/run_all.py --browser` exited 0, ALL PASSED: 32 headless suites; gameplay 361,
smooth 28, console 18, capabilities 7, GPU 12, golden 28, light 71, Firefox 2 checks.
/tmp/t88-phase4-refresh-tests.txt matches the prior full-suite summary except timings.
Current src matches the refresh-start snapshot and saved profile fingerprint. Map, diff
check and kandown doctor passed. Phase 4 checklist complete; t88 remains in Review.
tools/map.py regenerated PROJECT_MAP; existing TESTING changes cover the retired fields and
renderer/tool options. Files retained: render/{setup,cell,world,gpu/draw,gpu/textures},
tools/{perf.py,perf-measure.js,perf-after-t88.json}, TESTING/PROJECT_MAP/agent-log and t88.
No commit; DESIGN and owner's Context untouched. Owner visual review remains.
## 2026-10-28: t88 phase 4 final verification (Codex)
- Resumed the existing Phase 4 changes and checked their diff, live-run driver/results and
screenshots. The completed 122.8-second real-GPU play-through recorded 7,355 ticks
(59.90/s), flying, 63 grains drilled, 23 sand vacuumed, 11 belts and 8 screws built with
mouse drags, sand feeding and water/magma. No errors, pause or stopped simulation.
Opened 01-drill.png and 03-flow.png again: bot, terrain and UI intact; sand crosses the
line and piles beyond its outlet. This evidence was retained, not rerun during resume.
Driver /tmp/t88-live.py; screenshots/raw results in shots/t88-phase4/.
- Fresh full default profile on the host GPU: `python3 tools/perf.py --compare
tools/perf-baseline.json --json tools/perf-after-t88.json`, exit 0, 215.5 seconds.
The JSON source fingerprint matches the current src/tools; baseline bytes match HEAD.
NVIDIA Blackwell, 24 cores, Chromium 156, 1920x1080 / 2048x1024, all 30 views and
12 live runs. This fresh run replaces the initial Phase 4 profile.
settled: 1.913 ms/tick (1.94x baseline), polling 0.392, main compute 1.520; 16 helpers, peak active 4.
factory: 2.386 ms/tick (1.53x baseline), polling 0.406, main compute 1.980; 16 helpers, peak active 5.
heat: 2.695 ms/tick (1.83x baseline), polling 0.420, main compute 2.275; 16 helpers, peak active 5.
All 7,137 sampled frames: zero CPU work or intervals >16.7 ms; every live run 60.00 fps.
CPU frame p50/p95, ms (zoom 2 / zoom 6):
| Scene | Pixel | Smooth |
|---|---|---|
| settled | 7.03/8.50 / 2.76/3.89 | 8.47/9.87 / 2.93/4.08 |
| factory | 8.38/9.82 / 5.00/6.13 | 9.54/10.77 / 4.98/6.08 |
| heat | 8.80/10.17 / 4.84/5.84 | 9.92/11.27 / 5.07/6.07 |
- Phase history, factory/pixel/zoom 2 (ms; earlier columns from the entries below):
| Metric | Baseline | After 2a | After 2b | After 2d | After 3 | Final 4 |
|---|---:|---:|---:|---:|---:|---:|
| drawWorld | 24.55 | 9.98 | 6.79 | 2.53 | 2.44 | 2.41 |
| CPU frame p50 | 31.70 | 15.40 | 10.25 | 9.28 | 8.48 | 8.38 |
| sim/tick | 3.657 | unrecorded | unrecorded | 4.200 | 2.495 | 2.386 |
Final factory drawWorld 10.20x and CPU frame p50 3.78x faster than baseline.
Smooth factory zoom-2 CPU p50: baseline 34.71 -> 2c 14.5 -> 2d 10.60 -> 3 9.59
-> final 9.54 ms (2a/2b smooth figures unrecorded).
Phase 4 itself has no claimed speedup over 3; differences are run variation.
- Cleanup reviewed with whole-tree src/test/tools/docs references: 23 code lines removed,
14 added (net -9). Unused Canvas2D backing objects and CPU-light profiler fields removed;
cell.js/objects.js, GPU light grid, live flags and test helpers retained. Updated TESTING's
tool/renderer descriptions, regenerated PROJECT_MAP. No simulation code changed.
- Polling retained: no cheap safe overlap found. The existing main-thread batch already
overlaps helper work; subsequent serial work needs its result. Async wait would require
changes through chunkPasses/simMove/heatPass/simTick/frame and shared-state guards for
input, save and render. Non-blocking semantics confirmed against
[MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/waitAsync)
and [ECMAScript](https://tc39.es/ecma262/multipage/structured-data.html#sec-atomics.waitasync).
- Recommendation: YES to a separate whole-simulation-worker project. Auxiliary instrumentation
(/tmp/t88-orchestration.py and .json, factory/pixel/2 and heat/smooth/2, 599 ticks each)
measured dispatch/decode/merge at 0.55-0.60 ms/tick, local batch 1.11-1.35 and polling
0.50-0.60. These are wrapped auxiliary runs, not the unwrapped final real-loop measurements.
Current final live sim p50 is 2.09-2.69 ms/frame; estimate 2-3 ms main-thread budget recovered
before message/snapshot costs, not an fps increase in these already-60-fps scenes.
No sampled frames miss 16.7 ms. Worst zoom-2 CPU p95 11.27 leaves 5.40 ms;
p99 12.64 leaves 4.02 ms against 16.667. Expected benefit is headroom and responsiveness.
Cost: command messages for flight/drill/vacuum/build/interact, undo/blueprints/dev console;
worldgen/load/save/export/pause/step ownership; coherent render/rare-cell/HUD/bag/economy/
hover/minimap snapshots while the next tick writes elsewhere; integrate the helper pool.
Deterministic command order, save consistency and input latency need verification.
No whole-simulation worker implemented.
- tools/test_perf.py exited 0: headers, summaries/comparison, deterministic sim and both-look
image parity; inspected both new perf-readout screenshots. Initial sandboxed profiler failed
to acquire WebGPU; host-GPU retry passed. Final `python3 test/run_all.py --browser`
exited 0, ALL PASSED: 32 headless suites; gameplay 361, smooth 28, console 18,
capabilities 7, GPU 12, golden 28, light 71, Firefox 2 checks. Full output:
/tmp/t88-phase4-verified-tests.txt. Map, diff check and kandown doctor passed.
t88 moved to Review; owner visual review and Claude commit remain.
- Files: existing Phase 4 render/setup/cell/world and GPU draw/textures, perf.py/perf-measure.js/
perf-after-t88.json, TESTING/PROJECT_MAP/agent-log and t88 tracking. No commit, no DESIGN edit;
owner's Context unchanged. Owner visual review remains.
## 2026-10-28: t88 phase 3 resume, final review and verification (Codex)
- Preserved the interrupted uncommitted implementation and reviewed the schedule, shared
writes, deferred effects, startup gate, test changes and profiler against the Phase 3 brief.
- Reproduced a deferred-liquid bug before fixing it: replaying liquid() rolled the movement
chance twice. Extracted liquidSearch() so the deferred search resumes at its exact direction
and distance without another random draw; removed the now-unneeded RNG replay records.
Added a failing-then-passing regression and a long water shelf to the repeated 320-tick
inline/1/2/8-worker and reversed-order fixture.
- Chunk passes now skip per-cell renderDirty stores in wakeXY/wakeHeatXY; the scanned chunk
and write halo provide their invalidation. Outside passes, explicit invalidation remains.
- All headless suites passed. Initial sandboxed browser gate failed (no GPU device); an
elevated capability check confirms RTX 5070 WebGPU, isolation and 24 cores. Firefox still
has no adapter and correctly requires WebGPU. Final host-GPU browser gate in progress.
- Files additionally changed on resume: sim/{cells,workers,world}, test/t_threads.js,
PROJECT_MAP and tracking. Full fresh profile, two-minute live run and screenshot inspection
pending. No commit; DESIGN and owner's Context untouched.
## 2026-10-27: t88 phase 3, multi-core simulation (Codex; verification in progress)
- SharedArrayBuffer allocation for world arrays (ArrayBuffer only when unavailable in Node).
Four (chunkX&1, chunkY&1) movement passes then four heat passes. Each chunk scopes RS to
hash(world seed,tick,chunk,pass), restoring the global machine/worldgen RNG afterwards.
Long liquid reads outside a 24-cell horizontal halo are deferred and replayed in chunk order;
movement writes reach at most 8 cells, so parallel read/write regions cannot overlap.
- Pass-local wake/heat flags merge by maximum; per-chunk heat outputs merge first. Discoveries,
events, fuel ignition counts, wheel flow, cloud moisture, broken machines and partLost merge
in chunk order. Flyers/launchGrain remain reachable only from main-thread transport, not these
cell passes; their existing state is included in the determinism hash. Direct shared writes
are disjoint cell/powder outputs or idempotent render/roof bits.
- Browser Blob pool uses min(16,hardwareConcurrency-2,setting), at least one. Main thread owns
machines, transport and game logic, processes a batch in each pass, then polls Atomics.load.
No Atomics.wait on the document: it is prohibited by
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics/wait
and https://tc39.es/ecma262/2024/multipage/structured-data.html. Polling is synchronous and
occupies the main core; it preserves atomic ticks so input/save/render cannot race workers.
No whole-simulation worker or asynchronous rendering overlap. Worker timeouts/errors stop
frame simulation. Small jobs run inline and larger batches use only enough helpers to
amortize dispatch (32 movement or 8 heat chunks per helper). Pool setting applies on load.
- Invalidation: remove swap's duplicate hot marks, gas marks and repeated coordinate-to-chunk
function calls. Each scanned movement chunk marks its halo once; heat marks conduction edges.
wake functions reuse their already computed chunk index for one direct render bit store;
rare swaps/building/fog/dig/background writes retain explicit marks. Paused clean draws upload
zero world cells. GPU equality passes without changing any tolerance.
- Measurements so far: fresh Phase 2 settled/factory/heat 3.537/4.077/5.824 ms/tick vs frozen
baseline 3.715/3.657/4.938. Isolated removal of hot dirty calls in a temporary Phase 2 copy
gave 3.530/3.921/5.936: it did NOT recover the full 15% on its own. Do not claim it did.
Initial unbatched pool factory 4.21; batched trial 3.04 ms/tick. Full final matrix pending.
- Final full matrix (`/tmp/t88-phase3-final.json`, `/tmp/t88-phase3-final-perf.txt`, 218.0 s),
RTX 5070, 24 logical cores, 1920x1080, 2048x1024 world. Pool 16 helpers, peak active 4
(settled) / 5 (factory, heat), plus main-thread batch. All values below are ms.
settled: baseline 3.715 -> wall/main occupied 1.897 (1.96x); main compute excluding polling 1.624, polling 0.272.
factory: baseline 3.657 -> wall/main occupied 2.495 (1.47x); main compute excluding polling 2.037, polling 0.458.
heat: baseline 4.938 -> wall/main occupied 2.649 (1.86x); main compute excluding polling 2.236, polling 0.412.
Real-loop CPU work p50/p95, zoom 2 / zoom 6; pixel then smooth:
settled: pixel 7.08/8.58 / 2.77/3.83; smooth 8.46/9.98 / 2.98/4.13.
factory: pixel 8.48/10.05 / 4.96/5.95; smooth 9.59/10.80 / 5.13/6.12.
heat: pixel 8.53/10.56 / 4.88/6.03; smooth 10.05/11.51 / 5.17/6.21.
Frame intervals p50/p95 are 16.67/16.67 for all these runs (60 fps). Tick wall time and
occupied main-thread time are equal: polling blocks this core; rendering does not overlap.
The final threaded sim meets the baseline target in every scene; standalone dirty-only
recovery remains unproven/unmet (numbers above). Recommend a future whole-simulation worker
to remove the remaining 1.90-2.65 ms from the main thread; it would require an explicit
ownership/input/save boundary and is outside this phase.
- Re-baseline: three worldgen material hashes and seeded pile bounds (57x17 -> 46x19 grains).
The heat oracle retains its unoptimized per-cell computation, updated for buffered pass
outputs, and is compared against all four new passes. Same throughput assertions retained:
conveyor feed heap widened, downhill-screen warm-up lengthened; load-wake deadline kept at
eight ticks, fuel consumption checked later instead of demanding a random burn on that tick.
Frozen renderer PNGs and tolerances unchanged: original Phase 2 world bytes captured from the
Phase 2 simulation (only invalidation edits) in the temporary copy, compressed as golden-world fixtures, so changed
terrain doesn't get counted as changed renderer pixels.
- Headless suites pass; repeated inline/1/2/8 real-worker hashes pass (world arrays + JS effects).
Extended test passes 320 busy ticks including stamp wrap, reversed chunk order, Node fallback.
First full browser gate: gameplay 361, smooth 28, console 18, capabilities 7, GPU equality 12,
light 71 and Firefox 2 pass; golden sky failed due changed worldgen terrain. Frozen-input
golden rerun, final browser gate, full profile and 2-minute play-through pending.
- Files: sim/{world,cells,heat,workers,fog,machines,weather}, ui/{threads,title,state,settings,loop},
render/gpu/device, build.py/manifest; harness, threads/heat/load/corner/conveyor/screen/capability/
golden tests and fixtures; profiler, README, TESTING, PROJECT_MAP and t88 tracking.
No commit; DESIGN and owner's task context untouched. Phase 3 remains in progress.
## 2026-10-27: t88 phase 2d, required WebGPU and dirty uploads (Codex)
- Per-32x32-chunk `renderDirty` marks cell writes, heat with its conduction halo, stationary
fire life, fog, drill damage, tree growth, eviction and block edits. `gpu/uploads.js` merges
horizontal dirty chunks into strips; clean resident chunks survive scrolling. GPU byte
readback matches the full CPU data after 240 busy ticks; an unchanged frame uploads no world cells.
- Both looks stay within default WebGPU limits: four storage textures, sixteen sampled textures,
eight storage buffers per shader stage, 8192 texture dimension; timestamp queries optional.
Smooth colour/geometry and glow use two passes. All pipelines compile asynchronously at startup;
both looks submit behind the loading screen. No fallback: missing/insufficient adapters cannot
start. Loss tries one device recreation; failure stops frames, autosaves, offers Reload (or
Export if saving fails). Firefox 157 on Linux shows the required-WebGPU notice.
- Removed `render/light.js`, `smooth.js`, `smooth-shaders.js`, `slope-belt-shader.js`, the CPU
plain-colour loop, renderer setting/tool switch and Canvas2D `drawCracks`. The rare CPU shade
classifier remains in `cell.js`; no CPU light sweep. Drill contours now use GPU colour shading
from `digp` and the same three seeded Perlin fields at screen resolution.
- Before removal captured 28 legacy reference crops (renderer, joins, sky, lamp/opening/magma/fire;
both looks, zoom 12/2), 384x288 PNGs under `test/fixtures/golden/` (1.7 MB total). All pass:
mean RGB error 0.012-0.287/255; <=0.15% pixels over 8. Golden test allows mean <0.5 and <0.3%
pixels over 8 for smooth AO rounding. Both contact sheets and pixel/smooth drill screenshots
inspected. The zoom-12 joins crop was recentered on actual geometry using the original renderer
built in a temporary checkout; the four joins crops were recaptured there.
- Final full matrix (1920x1080, RTX 5070), drawWorld zoom 2/6 (ms; baseline -> final):
settled pixel 21.78/2.71 -> 2.64/0.44, smooth 25.64/3.08 -> 5.16/0.71;
factory pixel 24.55/2.89 -> 2.53/0.47, smooth 28.20/3.34 -> 3.93/0.85;
heat pixel 24.35/3.41 -> 2.19/0.34, smooth 27.43/3.54 -> 3.51/0.85.
Factory uploads zoom 2/6: pixel 6.55/1.76 -> 0.53/0.09 ms, smooth 8.05/1.92 -> 1.88/0.44 ms
(fresh before/after runs). Real-loop CPU p50/p95 zoom 2/6: pixel 9.28/10.56 and 6.63/7.74 ms;
smooth 10.60/12.03 and 6.66/7.65 ms. Zoom 2 CPU p50 baseline pixel/smooth 31.70/34.71 ms.
Standalone sim is a remaining regression vs the old baseline: factory 3.66 -> 4.20 ms/tick,
heat 4.94 -> 5.65; settled 3.71 -> 3.49. No simulation behaviour changed (output diff exact).
Full default matrix and comparison: `/tmp/t88-final.json`, `/tmp/t88-final.log` (222.5 s).
- Isolated 960x600 warm-up check: async pipeline compilation 125.55 ms at startup; world warm-up
23.73 ms while loading; first visible frame 4.87 ms, following frames 2.10-12.47 ms (including
normal scheduling/GC variation). Look/zoom timings are in `/tmp/t88-gpu-final.log`; no pipeline
creation occurs on switches. First switch to smooth at zoom 12: 3.42 ms (normal max 3.51);
first zoom 2 smooth: 6.01 ms (1.85 ms above the normal max of 4.16); zoom 6 smooth
3.74 ms (normal max 3.38). All below a 16.67 ms frame budget; no first-use shader compilation.
These timings include waiting for submitted GPU work.
- Files: `sim/{world,cells,heat,fog,drill,tools,nature,machines,blocks,actions,conveyors}.js`,
`render/{world,cell,setup}.js`, `render/gpu/{uploads,cracks,surface,colour,device,draw,smooth,textures}.js`,
`ui/{title,session,claims,state,settings,loop,perf,preview-drill}.js`, manifest; browser tests,
`test/{light-cases,light-readback,t_render_dirty}.js`, golden fixtures; profiler/shot tools,
`tools/browser.py`, README, TESTING, PROJECT_MAP, t88 record. Old `test/t_light.js` moved to actual GPU checks,
`test/gpu-readback.js` retained for the play test. DESIGN and other tasks untouched.
- Verification: final `python3 test/run_all.py --browser` exited 0, ALL PASSED (361 play,
28 smooth, 18 console, 6 capabilities, 12 GPU, 28 golden, 71 light, 2 Firefox checks;
all headless suites, including nine render-dirty checks). Retained headless outputs match
before/after exactly (timing lines ignored). `tools/test_perf.py`, map generation, diff check
and kandown doctor passed. Early gate caught premature GAME.running during warm-up; fixed by
warming before enterGame. No commit. Phase 2 implementation complete; Phase 3 next. Owner
visual review pending; other GPU hardware and Firefox with an actual adapter not tested.
## 2026-10-27: t88 phase 2c, smooth look on WebGPU (Codex + Claude Code)
- WGSL port of the smooth look (`src/render/gpu/smooth.js`, `smooth-shader.js`, `smooth-slope.js`):
the GPU colour pass writes all four smooth inputs for plain cells; the CPU rare-cell overrides
(`cell.js`, now also mold walls) fill them for built pieces and objects (12.73/3.41/0.38% of
factory cells at zoom 12/6/2). Field AO via three GPU mip passes; nine-tap glow kept; no
WebGPU `smoothPace` (max GPU time 1.24 ms). Legacy still selectable.
- Checked by Codex: 21 screenshot pairs legacy vs WebGPU, mean RGB error 0.01-0.22/255, small
counts off by more than 8 (thin AO and edge rounding), no missing shapes or open joins; input
readbacks equal legacy. Smooth factory drawWorld zoom 2 28.2 -> 10.0 ms; real loop zoom 2 CPU
p50/p95 34.7/39.0 -> 14.5/15.7 ms. Claude did not inspect the images (token budget).
- Open for 2d: adapters with fewer than five storage textures per stage fall back to legacy.
- Tests: `python3 test/run_all.py --browser` passed.
## 2026-10-27: t88 phase 2b, lighting on the GPU (Codex + Claude Code)
- `src/render/gpu/light.js`: light sources per light cell (transmittance, sky from `roofY`, heat,
bot lamp), propagation as a left/up then right/down relaxation on float textures (the same
light paths as the CPU sweep), then packing. The WebGPU path no longer calls `computeLight`;
the upload rectangle widens by the light margin. Fixed CPU light buffers keeping stale sizes
after a renderer switch. Scene `tools/scenes/t88-light.js` (lamp, opening, magma, fire).
- Checked: light scenes legacy vs WebGPU, ImageMagick MAE 16-27/65535 (<0.1/255), identical by
eye; `test/t_gpu.py` light parity in full and fast modes. Profile (factory, pixel): drawWorld at
zoom 2 24.55 (baseline) -> 9.98 (2a) -> 6.79 ms, CPU light 0, uploads now the biggest part
(4.87 ms); real loop zoom 2 CPU p50/p95 31.7/35.3 -> 10.25/11.48 ms, a steady 60 fps.
- Commit 31e3005 went in after a full-suite run where `t_gpu.py` timed out once (a 30 s page wait
under load): the commit sat after a heredoc, outside the test gate. The suite was re-run and
passed in full, and `t_gpu.py` passes alone. Codex hit its usage limit before the final
checks; Claude Code ran them.
## 2026-10-27: t88 phase 2a, WebGPU pixel look (Claude Code + Codex)
- New `src/render/gpu/` (device, textures, colour compute, glow blur, draw) renders the pixel look
on a `#gpu` canvas under `#game`, which becomes a transparent overlay in WebGPU mode. The GPU
colours plain cells (background, materials with noise and grain, loose shade, fire, water,
ALPHA, digp shake, incandescence, lens, fog); the CPU colours only rare cells (built blocks,
screw/slope neighbours, bars, flyers, ghosts) through the extracted per-cell code
(`src/render/cell.js`, `src/render/objects.js`). World arrays upload per visible rectangle with
`queue.writeTexture`; light is still `computeLight` on the CPU, multiplied on the GPU.
`SET.renderer` = webgpu | legacy (Settings). The smooth look still uses the WebGL2 path.
- Screenshots legacy vs WebGPU: mean abs RGB error 0.112 / 0.062 / 0.034 at zoom 12/6/2, no pixel
off by more than 8. Factory scene drawWorld at zoom 2: 24.55 -> 9.98 ms (overrides 1.81, uploads
2.39, CPU light 5.59; GPU time 0.06 ms). Real loop zoom 2 CPU p50/p95: 31.7/35.3 -> 15.4/16.7 ms.
- Tests: `python3 test/run_all.py --browser` all passed (new `test/t_gpu.py`). Not checked:
Firefox WebGPU (no adapter in Playwright Firefox on Linux).
## 2026-10-27: t88 phase 1, serving and capability checks (Claude Code + Codex)
- The game must be served with COOP/COEP (README has nginx and Caddy lines; opening from disk is
dropped). The title screen checks `crossOriginIsolated` and a WebGPU adapter (`CAPS`,
`checkCapabilities` in `src/ui/title.js`) and shows a notice; warnings only until
`REQUIRE_GPU_AND_THREADS` is flipped in Phases 2-3.
- Browser tests and tools load through `tools/browser.py` + `tools/server.py`, on the real GPU by
default (`--gpu soft` / `DEEPGRAIN_GPU=soft` for SwiftShader). `tools/caps.py` prints isolation
and WebGPU per browser; `test/firefox_smoke.py` runs in `run_all.py --browser` when Firefox is
installed. Chromium 156: isolated, WebGPU on the RTX 5070. Playwright Firefox 157: isolated, no
WebGPU (Linux).
- Tests: `python3 test/run_all.py --browser` all passed on the real GPU.
## 2026-10-27: t88 phase 0, profiler and baseline (Claude Code + Codex)
- `tools/perf.py` rewritten: real GPU (ANGLE Vulkan flags), served by the new `tools/server.py`
with COOP/COEP (`tools/serve.py` sends them too), zooms 12/6/4/3/2 x pixel/smooth x scenes
settled/factory/heat (`tools/perf-scenes.js`), drawWorld split by `PERF_MARKS`, per-step sim
timings, 10 s real runs with p50/p95/p99, `--json`/`--compare`. Baseline `tools/perf-baseline.json`.
In-game perf readout shows world, light, overlays, move, heat, rest (`src/ui/perf.js`).
- Baseline (1920x1080, RTX 5070): zoom 2 smooth heat scene drawWorld 27.4 ms (colour loop 21.1,
light 5.5, upload 0.6); sim 3.7-4.9 ms/tick (simMove 2.0-2.3, heatPass 1.5-2.6); real loop at
zoom 2 runs at 30 fps (CPU work p50 30-37 ms), at zoom 6 at 60 fps (7-9 ms).
- Files: `tools/perf.py`, `tools/server.py`, `tools/serve.py`, `tools/perf-measure.js`,
`tools/perf-scenes.js`, `tools/test_perf.py`, `tools/perf-baseline.json`, `src/ui/perf.js`,
`src/render/world.js`, `src/render/smooth.js`, `src/ui/loop.js`, `src/styles/style.css`,
`src/manifest.json`, `test/t_perf.js`, `docs/TESTING.md`, `docs/PROJECT_MAP.md`.
- Tests: `python3 test/run_all.py --browser` all passed. Not measured yet: GPU time (timestamp
queries come with WebGPU), Firefox.
## 2026-10-27: OS pointer hidden over the game (Claude Code)
- The canvas used `cursor: crosshair`, so the system pointer showed on top of the game's own aim;
it was hidden only while a gamepad was in use. Now `cursor: none` on the canvas always; the
gamepad's `nocursor` toggling is gone. Files: `src/styles/style.css`, `src/ui/input.js`,
`src/ui/gamepad.js`.
## 2026-10-27: t77 identify ores with the cursor (Claude Code + Codex)
- New `src/ui/cursor.js`: `oreLike`, `bitNeeded`, `pingVisible` (a ping's cells, while showing),
`hoveredVein` (cached capped flood fill and edges), `cursorLabelAlpha` (2 s then fade).
`hoverInfo` identifies pinged cells under fog and says "needs <bit>"; `drawCursorLabel` honours
the new Off / Ores only / Everything setting (`SET.cursorLabel`, booleans migrated);
`drawHoveredVein` in `src/render/overlays.js` draws the outline.
- Tests: `test/t_cursor.js`, `test/t_smooth.py`, `test/play.py`; `python3 test/run_all.py --browser`
all passed. Open: the outline keeps its old shape if the vein is drilled while hovered, until
the cursor moves to another cell.
## 2026-10-27: t75 ping recharge, natural ore colour, glints (Claude Code) ## 2026-10-27: t75 ping recharge, natural ore colour, glints (Claude Code)
- 10 s recharge (`PING_RECHARGE`, `pingWait`) with a drill panel chip. `pingImage` draws veins in - 10 s recharge (`PING_RECHARGE`, `pingWait`) with a drill panel chip. `pingImage` draws veins in
+18 -4
View File
@@ -37,6 +37,7 @@
"sim/bot.js", "sim/bot.js",
"sim/ledger.js", "sim/ledger.js",
"sim/weather.js", "sim/weather.js",
"sim/workers.js",
"sim/tick.js", "sim/tick.js",
"sim/worldgen-helpers.js", "sim/worldgen-helpers.js",
"sim/worldgen.js", "sim/worldgen.js",
@@ -45,17 +46,28 @@
], ],
"client": [ "client": [
"render/setup.js", "render/setup.js",
"render/light.js",
"render/slope-belts.js", "render/slope-belts.js",
"render/diagonal-screws.js", "render/diagonal-screws.js",
"render/cell.js",
"render/objects.js",
"render/gpu/cracks.js",
"render/gpu/colour.js",
"render/gpu/shaders.js",
"render/gpu/light.js",
"render/gpu/smooth-slope.js",
"render/gpu/smooth-shader.js",
"render/gpu/surface.js",
"render/gpu/smooth.js",
"render/gpu/device.js",
"render/gpu/textures.js",
"render/gpu/uploads.js",
"render/gpu/draw.js",
"render/world.js", "render/world.js",
"render/machines.js", "render/machines.js",
"render/overlays.js", "render/overlays.js",
"render/smooth.js",
"render/slope-belt-shader.js",
"render/smooth-shaders.js",
"ui/audio.js", "ui/audio.js",
"ui/state.js", "ui/state.js",
"ui/threads.js",
"ui/title.js", "ui/title.js",
"ui/saves.js", "ui/saves.js",
"ui/claims.js", "ui/claims.js",
@@ -69,6 +81,7 @@
"ui/selection.js", "ui/selection.js",
"ui/selection-pickers.js", "ui/selection-pickers.js",
"ui/selection-panels.js", "ui/selection-panels.js",
"ui/cursor.js",
"ui/info.js", "ui/info.js",
"ui/devconsole.js", "ui/devconsole.js",
"ui/input.js", "ui/input.js",
@@ -79,6 +92,7 @@
"ui/preview-selection.js", "ui/preview-selection.js",
"ui/events.js", "ui/events.js",
"ui/map.js", "ui/map.js",
"ui/perf.js",
"ui/loop.js" "ui/loop.js"
] ]
} }
+276
View File
@@ -0,0 +1,276 @@
// Rare-cell shading also treats built pieces as transparent surroundings.
function builtForShade(i, x, y) {
return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i];
}
// CPU colour rules for the rare-cell WebGPU overrides.
function cellPixel(x, y) {
const { bw, bh, px, vx0, vy0, smoothOn: sm, rain: rk } = R;
const bx = x - vx0, by = y - vy0, row = by * bw;
if (R.gpuOn) { if (!gpuOverrideHas(row + bx)) GPU.overrideCount++; if (sm) GPU.overrideMask[row + bx] = 255; }
const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask;
const OUT = sm ? 0x04000000 : 0xff000000;
if (x < 0 || y < 0 || x >= W || y >= H) {
px[row + bx] = OUT;
if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; }
return;
}
const useFog = SET.fog, grain = SET.grain, incand = SET.incandescence;
const anim = R.frame, lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
const glowOn = SET.glow !== 'off' && !lens, gpx = R.glowPx, gw = R.glowW;
const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW;
const yw = y * W;
let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y];
if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; }
let fv = 255;
if (useFog) {
const fx = x / FOG, j = Math.floor(fx), t = fx - j, j1 = Math.min(FW - 1, j + 1);
const a = fog[fr + j], b = fog[fr + j1], c = fog[fn + j], d = fog[fn + j1];
if ((a & b & c & d) !== 255) fv = (a + (b - a) * t) * (1 - ft) + (c + (d - c) * t) * ft;
if (!fv) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } return; }
// ease the fade into black (smoothstep) and break up the half-resolution steps with grain
// noise that is strongest mid-fade and nothing at either end, so the outer edge has no rim
if (fv < 255) {
let s = fv / 255;
s = Math.min(1, Math.max(0, s + (noiseB[yw + x] - 128) * (0.6 / 128) * s * (1 - s)));
fv = Math.max(1, 255 * s * s * (3 - 2 * s));
}
}
const i = yw + x, m = mat[i], nz = noiseB[i] - 128;
const T = temp[i];
// Also used by the thermal lens, which always draws in pixels.
let looseShade = 1;
if (!sm && m !== EMPTY && !builtForShade(i, x, y)) {
if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065;
else if (PH[m] === PH_SOLID && (
(x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) ||
(x + 1 < W && PH[mat[i + 1]] === PH_POWDER && !part[i + 1]) ||
(y > 0 && PH[mat[i - W]] === PH_POWDER && !part[i - W]) ||
(y + 1 < H && PH[mat[i + W]] === PH_POWDER && !part[i + W])
)) looseShade = 0.72;
}
let r, g, b;
if (lens) {
const ti = thermIdx(T);
if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; }
else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; }
const p = part[i];
r *= looseShade; g *= looseShade; b *= looseShade;
if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; }
if (fv < 255) { r *= fv / 255; g *= fv / 255; b *= fv / 255; }
px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
return;
}
// background
let br, bgc, bb, sky = false;
const tb = bgm[i];
if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } }
else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; }
else {
const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt;
const v = sm ? 0 : nz * 0.06;
br = L[0] + v; bgc = L[1] + v; bb = L[2] + v;
}
if (sm) {
// the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one
bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255);
if (tb) {
const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1;
const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f));
tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr;
} else tpx[row + bx] = 0;
}
if (m === EMPTY) { r = br; g = bgc; b = bb; }
else {
const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128;
r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v;
r *= looseShade; g *= looseShade; b *= looseShade;
if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; }
else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; }
else if (m === BELTL || m === BELTR) {
const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3;
if (ly === 0 || ly === 3) {
// top run moves with the load, bottom run returns the other way
const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1;
const k = ly === 0 ? 1 : 0.72;
r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k;
} else if (lx === 1 || lx === 2) {
const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1;
r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers
} else { r = 46; g = 48; b = 54; }
} else if (m === PIPE) {
pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2];
} else if (m === SHAFT) {
const lx = x & 3, ly = y & 3;
if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } }
else { r -= 34; g -= 30; b -= 24; }
}
else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } }
else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } }
else if (m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) {
const lx = x & 3, ly = y & 3, frame = lx === 0 || lx === 3 || ly === 0 || ly === 3;
r = frame ? 96 : 38; g = frame ? 102 : 42; b = frame ? 112 : 50;
}
else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks
else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres
else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } }
else if (m === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } }
const al = ALPHA[m];
if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; }
const dp = digp[i];
if (dp) {
// shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it)
const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k;
r += lift; g += lift; b += lift * 1.08;
}
// built wall blocks read as tiles: light top-left edge, dark bottom-right edge
if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) {
const lx = x & 3, ly = y & 3;
const wc = blockP[(y >> 2) * BW + (x >> 2)];
const edge = isSlope(wc) && (wc === BP_SLOPE_BL || wc === BP_SLOPE_TR ? lx === ly : lx + ly === 3);
if (edge || lx === 0 || ly === 0) { r += 16; g += 16; b += 16; }
else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; }
}
}
// field parts drawn over contents
const sb = slopeBeltAt(i, x, y, sm);
let p = part[i];
if (p === P_FILTER && (sb >= 0 || isSlopeBelt(blockP[slopeSealOwner(i)]))) p = 0;
const db = diagonalScrewBlock(x, y, sm);
let diagonal = false;
if (db >= 0) {
const v = blockV[db], joins = diagonalJoins[db];
const sd = diagonalScrewShape(x + 0.5, y + 0.5, db, v, joins);
p = sm || sd <= 0 ? SCREW_PARTS[v] : 0;
diagonal = !!p;
if (sm) tpx[row + bx] = (joins << 24) | ((y - ((db / BW) | 0) * BS + 8) << 16) | ((x - (db % BW) * BS + 8) << 8);
}
if (p && sm && (IS_SCREW[p] || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } }
else if (p) {
if (IS_SCREW[p]) {
const on = netSat(i) > 0;
const vert = p === P_SCREW_U || p === P_SCREW_D;
const axis = diagonal ? Math.floor(diagonalAlong) : vert ? y : x;
const across = diagonal ? diagonalAcross + 2 : vert ? x & 3 : y & 3;
const dir = diagonal && blockV[db] >= 4 ? (blockV[db] >= 6 ? 1 : -1) : (p === P_SCREW_U || p === P_SCREW_L) ? 1 : -1;
if (diagonal ? Math.abs(diagonalAcross) > 1.38 || diagonalCap > -0.55 : across === 0 || across === 3) { // copper casing on the two long sides; its load shows through
if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; }
if (!diagonal && (axis & 3) === 0) { r -= 24; g -= 18; b -= 12; }
} else {
// screw flights: a diagonal stripe that walks along the tube when powered
const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0);
if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; }
else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); }
}
} else if (p === P_FILTER) {
const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1;
const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv;
if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides
const st = ((x + dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) {
if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; }
else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; }
}
} else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt
const st = ((x - dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; }
} else if ((lx === 0 && !beltLike(fb, 0)) || (lx === 3 && !beltLike(fb, 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends
else if (m === EMPTY) { // mesh holes, tinted with what falls through them
if ((x + y) & 1) { r = 40; g = 42; b = 48; }
else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops
else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; }
else { r = 24; g = 25; b = 28; }
}
} else if (p === P_GRATE) {
if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; }
} else if (p === P_GOPEN) {
if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; }
} else if (p === P_INTAKE) {
if (m === EMPTY) { r = 30; g = 32; b = 36; }
if ((x & 1) === 0) { r += 50; g += 54; b += 60; }
} else if (p === P_PIPE) {
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
}
}
const slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR);
if (slopeArt && !sm) {
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
}
const slopeDetail = slopeDetailAt(i, x, y, sm);
if (slopeDetail) {
const wave = slopeDetail === 2 && ((x + beltGrade(blockP[slopeWallOwner]) * y - slopePhase[slopeWallOwner]) & 3) < 2;
// ribs: light rubber with a pale cap; the far wall behind them: muted, corrugated rubber
const cap = slopeDetail === 1 && slopeWallOwner >= 0 && !cleatMask[i - W + beltGrade(blockP[slopeWallOwner])];
r = slopeDetail === 1 ? (cap ? 178 : 146) : wave ? 64 : 56;
g = slopeDetail === 1 ? (cap ? 182 : 150) : wave ? 68 : 60;
b = slopeDetail === 1 ? (cap ? 190 : 158) : wave ? 76 : 68;
}
// incandescence
if (T > 450 && m !== EMPTY && incand) {
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w;
if (glowOn && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) {
const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4;
gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s);
}
}
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
let top = 0xff000000;
if (sm) {
// what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid,
// 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile)
let cl = 0;
const pt = (p === P_PIPE && !pipeBodyCell(x, y)) || (p === P_FILTER && sb >= 0) ? 0 : p; // the open parts of a pipe block show what is in them
if (IS_SCREW[pt]) cl = SCREW_CLASS[pt];
else if (p === P_FILTER && sb < 0) {
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
cl = blockV[fb] & 128 ? 14 : 13;
tpx[row + bx] = ((0x20 | (fc && fc.inv ? 0x10 : 0) | beltCaps[fb]) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
}
else if (m === BELTL || m === BELTR) {
cl = m === BELTL ? 7 : 8;
tpx[row + bx] = beltCaps[(y >> 2) * BW + (x >> 2)] << 24;
}
else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4;
else if (m !== EMPTY) {
const ph = PH[m];
if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) ? 6 : 1;
else if (ph === PH_POWDER) cl = 2;
else if (ph === PH_LIQUID) cl = 3;
else cl = 5;
}
const wallCode = blockP[(y >> 2) * BW + (x >> 2)];
if (isSlope(wallCode) && ((m === EMPTY && !slopeCell(wallCode, x & 3, y & 3)) || (PH[m] === PH_SOLID && !(flags[i] & F_NAT)))) {
cl = wallCode;
const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]];
r = COLR[wm]; g = COLG[wm]; b = COLB[wm];
}
if (sb >= 0 && (slopeArt || PH[m] === PH_POWDER)) {
const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb];
const slopeClass = 23 + (code - BP_BELT_UP) * 2 + dir;
if (slopeArt) cl = slopeClass;
const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m.length ? f.m[(Math.floor((x + beltGrade(code) * y) / 2) & 65535) % f.m.length] : 0;
tpx[row + bx] = ((slopeBeltJoins[sb] | 128) << 24) | slopeClass |
((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8);
if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; }
}
if (slopeDetail && slopeWallOwner >= 0) {
// ribs and the far wall are both drawn by the wall shader, ribs from the same phase as the simulation
const wb = slopeWallOwner;
cl = beltGrade(blockP[wb]) < 0 ? 31 : 32;
tpx[row + bx] = ((128 | slopePhase[wb] | ((slopeBeltJoins[wb] & 12) << 1) | (blockV[wb] & 1 ? 32 : 0)) << 24) | cl |
((y - ((wb / BW) | 0) * BS + 8) << 16) | ((x - wb % BW * BS + 8) << 8);
}
top = cl << 24;
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !blockP[(y >> 2) * BW + (x >> 2)] && !mach[i] ? 0xff : 0); // terrain: any solid nothing owns, natural or not
}
if (fv < 255) {
const k = fv / 255; r *= k; g *= k; b *= k;
if (sm) {
const c = bpx[row + bx], t = tpx[row + bx];
bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k);
}
}
px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
}
+101
View File
@@ -0,0 +1,101 @@
// The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
function gpuColourShader(smooth) { return `
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
@group(0) @binding(0) var<uniform> u: Params;
@group(0) @binding(1) var<storage, read> lut: array<vec4f>;
@group(0) @binding(2) var<storage, read> land: array<vec4f>;
${['mat','flags','life','part','bgm','digp','noise','fog'].map((n,i)=>`@group(0) @binding(${i+3}) var ${n}T: texture_2d<u32>;`).join('\n')}
@group(0) @binding(11) var tempT: texture_2d<f32>;
@group(0) @binding(12) var overrideT: texture_2d<f32>;
@group(0) @binding(13) var overrideGlow: texture_2d<f32>;
@group(0) @binding(14) var outputT: texture_storage_2d<rgba8unorm, write>;
${smooth ? `@group(0) @binding(16) var backOut: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(17) var treeOut: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(18) var fieldOut: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(19) var backOver: texture_2d<f32>;
@group(0) @binding(20) var treeOver: texture_2d<f32>;
@group(0) @binding(21) var fieldOver: texture_2d<f32>;
@group(0) @binding(22) var maskOver: texture_2d<u32>;
` : ""}
${smooth ? "" : "@group(0) @binding(15) var glowT: texture_storage_2d<rgba8unorm, write>;"}
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
fn powder(p: vec2i) -> bool {
return all(p >= vec2i(0)) && all(p < vec2i(u.world.xy)) && phase(p)==${PH_POWDER} && textureLoad(partT,p,0).r==0u;
}
fn quant(c: vec3f) -> vec3f { return floor(clamp(c,vec3f(0),vec3f(255)))/255.; }
@compute @workgroup_size(8,8)
fn colour(@builtin(global_invocation_id) id: vec3u) {
let q=vec2i(id.xy); if (any(q>=vec2i(u.view.zw))) { return; }
let p=q+vec2i(u.view.xy); let x=p.x; let y=p.y;
var rgb=vec3f(0); var bloom=vec3f(0);
${smooth ? "var cl=4.; var backValue=vec4f(0); var treeValue=vec4f(0); var fieldValue=vec4f(1,0,0,0);" : ""}
if (all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
let m=textureLoad(matT,p,0).r; let nz=f32(textureLoad(noiseT,p,0).r)-128.;
let T=textureLoad(tempT,p,0).r; let mc=lut[m*3u]; let props=lut[m*3u+1u];
var fv=255.;
if (u.style.x>0.) {
let f=vec2f(p)/${FOG}.; let a=vec2i(f); let b=min(a+1,vec2i(u.world.zw)-1); let t=fract(f);
fv=mix(mix(f32(textureLoad(fogT,a,0).r),f32(textureLoad(fogT,vec2i(b.x,a.y),0).r),t.x),mix(f32(textureLoad(fogT,vec2i(a.x,b.y),0).r),f32(textureLoad(fogT,b,0).r),t.x),t.y);
if (fv>0. && fv<255.) { var s=fv/255.; s=clamp(s+nz*(0.6/128.)*s*(1.-s),0.,1.); fv=max(1.,255.*s*s*(3.-2.*s)); }
}
if (fv>0.) {
var shade=1.;
let built=props.x==${PH_SOLID}. && (textureLoad(flagsT,p,0).r & ${F_NAT}u)==0u;
if (${smooth ? "false" : `m!=${EMPTY}u && !built`}) {
if (props.x==${PH_POWDER}.) { shade=1.1+nz*0.00065; }
else if (props.x==${PH_SOLID}. && (powder(p+vec2i(-1,0)) || powder(p+vec2i(1,0)) || powder(p+vec2i(0,-1)) || powder(p+vec2i(0,1)))) { shade=0.72; }
}
if (u.effects.x>0.) {
let ti=u32(clamp(i32(T/4.),0,550)); let thermal=lut[${NMAT*3+700}u+u32(u.effects.y)*551u+ti].rgb;
rgb=select(thermal*0.78+vec3f((mc.r+mc.g+mc.b)/3.*0.25),thermal*0.55,m==${EMPTY}u)*shade;
} else {
let tb=textureLoad(bgmT,p,0).r; let dp=f32(textureLoad(digpT,p,0).r);
let column=land[x]; let sky=land[i32(u.world.x)+y].rgb;
var back: vec3f;
if (${smooth ? "false" : "tb!=0u"}) { back=(lut[tb*3u+2u].rgb+vec3f(nz*lut[tb*3u].a/128.))*(1.+dp/700.); }
else if (f32(y)<column.x) {
back=sky;
if (u.style.y>0.01) { let gy=(sky.r+sky.g+sky.b)/3.*0.82; back+= (vec3f(gy)-sky)*u.style.y*vec3f(0.7,0.7,0.65); }
} else {
back=vec3f(${BG.basalt.join(',')});
if (f32(y)<column.y) { back=vec3f(${BG.dirt.join(',')}); }
else if (f32(y)<column.z) { back=vec3f(${BG.stone.join(',')}); }
else if (f32(y)<column.w) { back=vec3f(${BG.granite.join(',')}); }
${smooth ? "" : "back+=vec3f(nz*0.06);"}
}
${smooth ? `backValue=vec4f(quant(back),select(0.,1.,f32(y)<column.x));
if (tb!=0u) { treeValue=vec4f(quant((lut[tb*3u+2u].rgb+vec3f(nz*lut[tb*3u].a/256.))*(1.+dp/700.)),select(128./255.,1.,tb==${LEAVES}u)); }
cl=0.; if (m!=${EMPTY}u) { cl=5.; if(props.x==${PH_SOLID}.) { cl=1.; } else if(props.x==${PH_POWDER}.) { cl=2.; } else if(props.x==${PH_LIQUID}.) { cl=3.; } }
fieldValue=vec4f(select(0.,1.,cl==1.),select(0.,1.,cl==3.),select(0.,1.,cl==5.),select(0.,1.,tb!=0u));
` : ""}
rgb=back;
if (m!=${EMPTY}u) {
rgb=(mc.rgb+vec3f(nz*mc.a*${smooth ? `select(u.style.z*0.35,0.,props.x==${PH_SOLID}.)` : "u.style.z"}/128.))*shade;
if (m==${FIRE}u) { let f=f32(textureLoad(lifeT,p,0).r & 15u)*6.; rgb=vec3f(255.,120.+f,30.+f*0.5); }
else if (${smooth ? "false && " : ""}m==${WATER}u && ((x*3+y*5+(i32(u.style.w)>>2)) & 15)==0) { rgb+=vec3f(14.); }
rgb=back+(rgb-back)*props.y;
if (dp>0.) { let lift=dp/255.*(30.+sin(u.style.w*1.9+f32(x)*1.3+f32(y)*0.7)*6.); rgb+=lift*vec3f(1,1,1.08); }
}
if (T>450. && m!=${EMPTY}u && u.effects.z>0.) {
var gt=T;
if (props.x==${PH_SOLID}.) { gt+=nz*0.9; }
else if (m==${MAGMA}u) { gt+=sin(f32(x)*0.19+f32(y)*0.11+u.style.w*0.03+nz*0.04)*55.-30.; }
let gi=u32(clamp(i32((gt-400.)/4.),0,699)); let glow=lut[${NMAT*3}u+gi];
rgb=rgb*(1.-glow.a)+glow.rgb*glow.a;
if (u.effects.w>0. && fv>=128. && glow.a>0.3) { bloom=quant(glow.rgb*(glow.a-0.3)*1.4); }
}
}
rgb=quant(rgb*fv/255.);
${smooth ? "backValue=vec4f(quant(backValue.rgb*fv),backValue.a); treeValue=vec4f(quant(treeValue.rgb*fv),treeValue.a);" : ""}
}
}
// Flying grains replace colour, while the glow behind them remains.
let over=textureLoad(overrideT,q,0);
if (${smooth ? "textureLoad(maskOver,q,0).r>0u" : "over.a>0."}) { rgb=over.rgb; ${smooth ? "cl=over.a*255.; backValue=textureLoad(backOver,q,0); treeValue=textureLoad(treeOver,q,0); fieldValue=textureLoad(fieldOver,q,0);" : ""} bloom=max(bloom,textureLoad(overrideGlow,q/2,0).rgb); }
${smooth ? "textureStore(backOut,q,backValue); textureStore(treeOut,q,treeValue); textureStore(fieldOut,q,fieldValue);" : ""}
textureStore(outputT,q,vec4f(rgb,${smooth ? "cl/255." : "1"}));
${smooth ? "" : "if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); }"}
}`;
}
const GPU_COLOUR = gpuColourShader(false);
const GPU_COLOUR_SMOOTH = gpuColourShader(true);
+79
View File
@@ -0,0 +1,79 @@
// The drill's three seeded Perlin contour fields, uploaded once with the material tables.
function gpuCrackTables() {
const out = new Float32Array(3 * 512 * 4);
for (let l=0;l<3;l++) {
const rng=mulberry32(0xc7ac1+l), p=Array.from({length:256},(_,i)=>i);
for(let i=255;i>0;i--) { const j=Math.floor(rng()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
for(let i=0;i<512;i++) out[(l*512+i)*4]=p[i&255];
for(let i=0;i<256;i++) { const a=rng()*Math.PI*2; out[(l*512+i)*4+1]=Math.cos(a); out[(l*512+i)*4+2]=Math.sin(a); }
}
return out;
}
const GPU_CRACKS = `
fn crackNoise(p: vec2f, l: u32) -> f32 {
let base=${NMAT*3+700+551*3}u+l*512u;
let v=vec2u(vec2i(floor(p)) & vec2i(255)); let f=fract(p);
let p0=u32(lut[base+v.y].x); let p1=u32(lut[base+v.y+1u].x);
let a=lut[base+u32(lut[base+v.x+p0].x)].yz;
let b=lut[base+u32(lut[base+v.x+1u+p0].x)].yz;
let c=lut[base+u32(lut[base+v.x+p1].x)].yz;
let d=lut[base+u32(lut[base+v.x+1u+p1].x)].yz;
let t=f*f*f*(f*(f*6.-15.)+10.);
return mix(mix(dot(a,f),dot(b,f-vec2f(1,0)),t.x),mix(dot(c,f-vec2f(0,1)),dot(d,f-vec2f(1)),t.x),t.y)*1.41;
}
`;
// Screen-resolution colour output preserves the old sub-grain marching-square strokes.
const GPU_CRACK_COLOUR = `
struct Params { view:vec4f, world:vec4f, style:vec4f, effects:vec4f, camera:vec4f, light:vec4f, screen:vec4f, extra:vec4f }
@group(0) @binding(0) var<uniform> u:Params;
@group(0) @binding(1) var<storage,read> lut:array<vec4f>;
@group(0) @binding(2) var matT:texture_2d<u32>;
@group(0) @binding(3) var digpT:texture_2d<u32>;
@group(0) @binding(4) var outputT:texture_storage_2d<rgba8unorm,write>;
${GPU_CRACKS}
fn segmentDistance(p:vec2f,a:vec2f,b:vec2f)->f32 {
let d=b-a;return length(p-a-d*clamp(dot(p-a,d)/max(0.000001,dot(d,d)),0.,1.));
}
@compute @workgroup_size(8,8)
fn main(@builtin(global_invocation_id) id:vec3u) {
let q=vec2i(id.xy);if(any(q>=vec2i(u.screen.xy))){return;}
let pos=u.extra.xy+(vec2f(q)+0.5)/u.camera.z; let p=vec2i(floor(pos));
var colour=vec4f(0);
if(u.camera.z>=2.5 && all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
let m=textureLoad(matT,p,0).r;let damage=f32(textureLoad(digpT,p,0).r)/255.;
if(lut[m*3u+1u].x==${PH_SOLID}.) {
for(var l=0u;l<3u;l++) {
if(damage<array<f32,3>(0.1,0.42,0.72)[l]){continue;}
let scale=array<f32,3>(1./7.,1./3.6,1./2.)[l];
let at=vec2f(p)*scale+vec2f(0.37,0.61);
let a=crackNoise(at,l);let b=crackNoise(at+vec2f(scale,0),l);
let c=crackNoise(at+vec2f(scale),l);let d=crackNoise(at+vec2f(0,scale),l);
var pts:array<vec2f,4>;var n=0u;
if((a<0.)!=(b<0.)){pts[n]=vec2f(a/(a-b),0);n++;}
if((b<0.)!=(c<0.)){pts[n]=vec2f(1,b/(b-c));n++;}
if((d<0.)!=(c<0.)){pts[n]=vec2f(d/(d-c),1);n++;}
if((a<0.)!=(d<0.)){pts[n]=vec2f(0,a/(a-d));n++;}
let width=array<f32,3>(0.2,0.14,0.09)[l];
for(var stroke=0u;stroke<2u;stroke++) {
let lip=stroke==0u;let offset=select(0.,max(1./u.camera.z,0.12),lip);
var distance=100.;
for(var k=0u;k+1u<n;k+=2u) {
distance=min(distance,segmentDistance(pos-vec2f(p)-vec2f(0,offset),pts[k],pts[k+1u]));
}
let w=max(1./u.camera.z,width*select(1.,1.3,lip))*0.5;
let alpha=clamp((w-distance)*u.camera.z+0.5,0.,1.)*select(0.78-f32(l)*0.12,0.13,lip);
let rgb=select(vec3f(18,12,10),vec3f(255,240,220),lip)/255.;
colour=vec4f(rgb*alpha+colour.rgb*(1.-alpha),alpha+colour.a*(1.-alpha));
}
}
}
}
textureStore(outputT,q,colour);
}`;
const GPU_CRACK_DRAW = `
@group(0) @binding(0) var cracks:texture_2d<f32>;
@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f {
let p=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3));return vec4f(p[i],0,1);
}
@fragment fn fs(@builtin(position) p:vec4f)->@location(0) vec4f {return textureLoad(cracks,vec2i(p.xy),0);}
`;
+90
View File
@@ -0,0 +1,90 @@
// All pipelines compile during startup; rendering never falls back.
const GPU_LIMITS = { maxStorageTexturesPerShaderStage: 4, maxSampledTexturesPerShaderStage: 16, maxStorageBuffersPerShaderStage: 8, maxTextureDimension2D: 8192 };
function gpuLimitsOK(adapter) { return Object.entries(GPU_LIMITS).every(([k,v]) => adapter.limits[k] >= v); }
const GPU = { ok: false, canvas: document.getElementById('gpu'), error: '', gpuMs: null };
async function gpuInit(adapter) {
const started = performance.now();
try {
if (!gpuLimitsOK(adapter)) throw Error("Graphics adapter does not meet the required WebGPU limits.");
const features = adapter.features.has('timestamp-query') ? ['timestamp-query'] : [];
const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features, requiredLimits: GPU_LIMITS });
d.addEventListener('uncapturederror', e => gpuFail(e.error.message));
d.lost.then(info => { if (GPU.device === d) gpuRecover(info.message || 'Graphics device lost'); });
GPU.context = GPU.canvas.getContext('webgpu');
GPU.format = navigator.gpu.getPreferredCanvasFormat();
GPU.context.configure({ device: d, format: GPU.format, alphaMode: 'opaque' });
d.pushErrorScope('validation');
GPU.colour = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR }), entryPoint: 'colour' } });
GPU.colourSmooth = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR_SMOOTH }), entryPoint: 'colour' } });
await gpuSmoothInit();
for (const [name, code, entryPoint] of [['lightRoof',GPU_LIGHT,'roof'],['lightSources',GPU_LIGHT,'sources'],['lightSpread',GPU_LIGHT_SPREAD,'main'],['lightPack',GPU_LIGHT_PACK,'main']]) {
GPU[name] = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code}),entryPoint}});
}
GPU.blur = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_BLUR }), entryPoint: 'main' } });
GPU.draw = await d.createRenderPipelineAsync({ layout: 'auto', vertex: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'vs' }, fragment: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'fs', targets: [{ format: GPU.format }] }, primitive: { topology: 'triangle-list' } });
GPU.cracks = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:GPU_CRACK_COLOUR}),entryPoint:'main'}});
const cracks = d.createShaderModule({code:GPU_CRACK_DRAW});
GPU.crackDraw = await d.createRenderPipelineAsync({layout:'auto',vertex:{module:cracks,entryPoint:'vs'},fragment:{module:cracks,entryPoint:'fs',targets:[{format:GPU.format,blend:{color:{srcFactor:'one',dstFactor:'one-minus-src-alpha'},alpha:{srcFactor:'one',dstFactor:'one-minus-src-alpha'}}}]},primitive:{topology:'triangle-list'}});
const error = await d.popErrorScope(); if (error) throw error;
GPU.uniform = d.createBuffer({ size: 128, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
GPU.params = new Float32Array(32);
GPU.sampler = d.createSampler({ magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear' });
if (features.length) {
GPU.queries = d.createQuerySet({ type: 'timestamp', count: 4 });
GPU.resolve = d.createBuffer({ size: 256, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
GPU.readback = d.createBuffer({ size: 32, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
}
GPU.ok = true; GPU.error = ''; GPU.initMs = performance.now() - started;
return true;
} catch (e) { GPU.ok = false; GPU.error = e.message; return false; }
}
function gpuFail(message) {
GPU.ok = false; GPU.error = message;
console.warn('WebGPU renderer: ' + message);
if (GAME.running) gpuRecover(message);
}
async function gpuRecover(message) {
if (GPU.recovering || GPU.stopped) return;
GPU.ok = false; GPU.recovering = true;
const saved = !GAME.running || await saveWorld('auto', true);
const old = GPU.device;
// Resources belong to the lost device; force every retained cache to rebuild.
GPU.world = GPU.sky = GPU.viewKey = GPU.lightWorld = GPU.lightKey = null;
GPU.bindColour = GPU.lightDirections = GPU.queries = null;
GPU.timingBusy = false; GPU.lightFrame = undefined;
let restored = false;
try {
const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
if (adapter) restored = await gpuInit(adapter);
} catch (e) { GPU.error = e.message; }
if (restored) { if (old) old.destroy(); GPU.recovering = false; lastT = performance.now(); acc = 0; return; }
GPU.device = null; if (old) old.destroy();
GPU.stopped = true; GPU.recovering = false; GAME.paused = true;
dialog('Graphics stopped', '<p>The graphics device could not restart. '+
(saved ? 'Your world was autosaved. Reload to continue.' : 'Saving failed. Export your world before reloading.')+'</p>', [
...(!saved ? [{label:'Export world',fn:exportWorld}] : []),
{label:'Reload',fn:()=>location.reload()}
]);
}
// Submit both looks behind the loading screen, including their first real bindings.
async function gpuWarmWorld() {
await startThreads();
buildSky(); resizeRender(); snapCamera();
const look = SET.look, started = performance.now();
try {
for (const next of [look === 'smooth' ? 'pixel' : 'smooth', look]) {
SET.look = next; drawWorld(); await GPU.device.queue.onSubmittedWorkDone();
}
GPU.warmMs = performance.now() - started;
} finally { SET.look = look; }
}
// Tests sample this composite so a transparent overlay cannot pass world-colour checks.
function worldPixelContext() {
if (!R.gpuOn) return R.ctx;
// Canvas textures expire after presentation; redraw the retained result before copying.
const encoder = GPU.device.createCommandEncoder(); gpuPresent(encoder);
GPU.device.queue.submit([encoder.finish()]);
const c = document.createElement('canvas'); c.width = R.canvas.width; c.height = R.canvas.height;
const ctx = c.getContext('2d'); ctx.drawImage(GPU.canvas, 0, 0); ctx.drawImage(R.canvas, 0, 0);
return ctx;
}
+92
View File
@@ -0,0 +1,92 @@
// Only exceptional blocks and their geometric neighbours run the CPU colour rules.
const GPU_PATTERN = new Uint8Array(NMAT);
for (const m of [BELTL, BELTR, PIPE, SHAFT, GATE, CUTST, GRINDER, SAWB, PRESSB, RACKB, WOOD, LWOOD, HULL, SPRING, DOCKW]) GPU_PATTERN[m] = 1;
function gpuOverrideHas(k) { return R.smoothOn ? GPU.overrideMask[k] : R.px[k]; }
function gpuOverrides() {
R.px.fill(0);
if (R.smoothOn) {
if (!GPU.overrideMask || GPU.overrideMask.length !== R.px.length) GPU.overrideMask = new Uint8Array(R.px.length);
GPU.overrideMask.fill(0);
}
if (!GPU.blocks || GPU.blocks.length !== NB) GPU.blocks = new Uint8Array(NB);
const marked = GPU.blocks; marked.fill(0);
const bx0 = Math.max(0, (R.vx0 >> 2) - 3), by0 = Math.max(0, (R.vy0 >> 2) - 3);
const bx1 = Math.min(BW - 1, ((R.vx0 + R.bw) >> 2) + 3), by1 = Math.min(BH - 1, ((R.vy0 + R.bh) >> 2) + 3);
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx, code = blockP[b];
let rare = !!code;
if (!rare) for (let dy = 0; dy < BS && !rare; dy++) for (let dx = 0; dx < BS; dx++) {
const i = (by * BS + dy) * W + bx * BS + dx;
if (part[i] || mach[i] || occ[i] || GPU_PATTERN[mat[i]]) { rare = true; break; }
}
if (!rare) continue;
marked[b] = 1;
if (code === BP_SCREW || isSlopeBelt(code)) {
// Covers joins, the far wall and cleats, including cells outside the owning block.
for (let yy = Math.max(0, by - 3); yy <= Math.min(BH - 1, by + 2); yy++)
for (let xx = Math.max(0, bx - 2); xx <= Math.min(BW - 1, bx + 2); xx++) marked[yy * BW + xx] = 1;
}
}
if (R.smoothOn) for (const mo of molds) {
if (mo.x + 40 < R.vx0 || mo.x - 40 > R.vx0 + R.bw || mo.y + 40 < R.vy0 || mo.y - 40 > R.vy0 + R.bh) continue;
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) marked[(((i / W) | 0) >> 2) * BW + ((i % W) >> 2)] = 1;
}
GPU.overrideCount = 0;
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
if (!marked[by * BW + bx]) continue;
const x0 = Math.max(bx * BS, R.vx0), y0 = Math.max(by * BS, R.vy0);
const x1 = Math.min(bx * BS + BS, R.vx0 + R.bw, W), y1 = Math.min(by * BS + BS, R.vy0 + R.bh, H);
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) cellPixel(x, y);
}
}
function gpuDraw(lighting, glowOn, lightUpdate) {
const upload = performance.now();
gpuUpload(lighting, glowOn);
if (GPU.canvas.width !== R.canvas.width || GPU.canvas.height !== R.canvas.height) {
GPU.canvas.width = R.canvas.width; GPU.canvas.height = R.canvas.height;
}
const blit = performance.now(), d = GPU.device, encoder = d.createCommandEncoder();
const timed = GPU.queries && !GPU.timingBusy && (PERF_MARKS || SET.showPerf);
const compute = encoder.beginComputePass(timed ? { timestampWrites: { querySet: GPU.queries, beginningOfPassWriteIndex: 0 } } : {});
compute.setPipeline(GPU.colour); compute.setBindGroup(0, GPU.bindColour);
if (R.smoothOn) {
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
compute.setPipeline(GPU.colourSmooth); compute.setBindGroup(0, GPU.bindColourSmooth);
}
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
compute.setPipeline(GPU.cracks); compute.setBindGroup(0, GPU.bindCracks);
compute.dispatchWorkgroups(Math.ceil(R.canvas.width / 8), Math.ceil(R.canvas.height / 8)); compute.end();
if (glowOn && !R.smoothOn) for (let i = 0; i < 2; i++) {
const pass = encoder.beginComputePass(); pass.setPipeline(GPU.blur); pass.setBindGroup(0, GPU.bindBlur[i]);
pass.dispatchWorkgroups(Math.ceil(R.glowW / 8), Math.ceil(R.glowH / 8)); pass.end();
}
const lightTimed = lighting && (lightUpdate || GPU.lightFrame === undefined);
if (lightTimed) { gpuLight(encoder, timed); GPU.lightFrame = R.frame; }
if (R.smoothOn) gpuSmoothMips(encoder);
gpuPresent(encoder, timed);
if (timed) { encoder.resolveQuerySet(GPU.queries, 0, lightTimed ? 4 : 2, GPU.resolve, 0); encoder.copyBufferToBuffer(GPU.resolve, 0, GPU.readback, 0, lightTimed ? 32 : 16); }
d.queue.submit([encoder.finish()]);
if (timed) {
GPU.timingBusy = true;
GPU.timingPromise = GPU.readback.mapAsync(GPUMapMode.READ).then(() => {
const ns = new BigUint64Array(GPU.readback.getMappedRange()); GPU.gpuMs = Number(ns[1] - ns[0]) / 1e6;
GPU.lightMs = lightTimed ? Number(ns[3] - ns[2]) / 1e6 : 0;
GPU.readback.unmap(); GPU.timingBusy = false;
}).catch(() => { GPU.timingBusy = false; });
}
if (PERF_MARKS || SET.showPerf) {
perfMarks.override = perfMarks.color; perfMarks.upload = blit - upload; perfMarks.blit = performance.now() - blit;
perfMarks.gpuLight = GPU.lightMs || 0;
if (GPU.gpuMs !== null) perfMarks.gpu = GPU.gpuMs;
}
}
// Also used for readback after the browser has expired the presented canvas texture.
function gpuPresent(encoder, timed = false) {
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: GPU.context.getCurrentTexture().createView(), loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] }],
});
pass.setPipeline(R.smoothOn ? GPU.smooth : GPU.draw); pass.setBindGroup(0, R.smoothOn ? GPU.bindSmooth : GPU.bindDraw); pass.draw(3); pass.end();
const cracks = encoder.beginRenderPass({colorAttachments:[{view:GPU.context.getCurrentTexture().createView(),loadOp:'load',storeOp:'store'}],
...(timed ? {timestampWrites:{querySet:GPU.queries,endOfPassWriteIndex:1}} : {})});
cracks.setPipeline(GPU.crackDraw);cracks.setBindGroup(0,GPU.bindCrackDraw);cracks.draw(3);cracks.end();
}
+143
View File
@@ -0,0 +1,143 @@
// GPU sources retain the CPU roof and transmission rules; directional relaxation
// follows only left/up, then right/down, so it cannot introduce extra light paths.
const GPU_LIGHT = `
struct LightParams { grid: vec4f, world: vec4f, lamp: vec4f, levels: vec4f }
@group(0) @binding(0) var<uniform> u: LightParams;
@group(0) @binding(1) var<storage, read> lut: array<vec4f>;
@group(0) @binding(2) var<storage, read> columns: array<vec4f>;
@group(0) @binding(3) var matT: texture_2d<u32>;
@group(0) @binding(4) var flagsT: texture_2d<u32>;
@group(0) @binding(5) var partT: texture_2d<u32>;
@group(0) @binding(6) var bgmT: texture_2d<u32>;
@group(0) @binding(7) var machT: texture_2d<u32>;
@group(0) @binding(8) var blockT: texture_2d<u32>;
@group(0) @binding(9) var tempT: texture_2d<f32>;
@group(0) @binding(10) var<storage, read_write> roofs: array<i32>;
@group(0) @binding(11) var dest: texture_storage_2d<rgba16float, write>;
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
fn built(p: vec2i) -> bool {
return (phase(p)==${PH_SOLID} && (textureLoad(flagsT,p,0).r & ${F_NAT}u)==0u) ||
textureLoad(blockT,p/4,0).r!=0u || textureLoad(machT,p,0).r!=0u || textureLoad(partT,p,0).r!=0u;
}
fn loose(p: vec2i) -> bool {
for(var d=0;d<=12 && p.y+d<i32(u.world.y);d++) {
let q=p+vec2i(0,d); let ph=phase(q);
if(built(q)) { return true; }
if(ph!=${PH_POWDER} && ph!=${PH_LIQUID}) { return ph==${PH_EMPTY} || ph==${PH_GAS}; }
}
return false;
}
@compute @workgroup_size(64)
fn roof(@builtin(global_invocation_id) id: vec3u) {
let lx=i32(id.x); if(lx>=i32(u.grid.z)) { return; }
let x=(i32(u.grid.x)+lx)*i32(u.world.z);
if(x<0 || x>=i32(u.world.x)) { roofs[lx]=0; return; }
let end=min(i32(u.world.y),i32((u.grid.y+u.grid.w)*u.world.z));
var y=i32(columns[x].y);
for(;y<end;y++) {
let p=vec2i(x,y); let ph=phase(p);
if((ph==${PH_SOLID} || ph==${PH_POWDER} || ph==${PH_LIQUID}) && !built(p) && !((ph==${PH_POWDER} || ph==${PH_LIQUID}) && loose(p))) { break; }
}
roofs[lx]=y;
}
@compute @workgroup_size(8,8)
fn sources(@builtin(global_invocation_id) id: vec3u) {
let q=vec2i(id.xy); if(any(q>=vec2i(u.grid.zw))) { return; }
let p=(vec2i(u.grid.xy)+q)*i32(u.world.z); let res=i32(u.world.z);
var rgb=vec3f(0); var a=0.;
if(all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
let m=textureLoad(matT,p,0).r; let ph=phase(p);
var base=0.9;
if(built(p) || ((ph==${PH_POWDER} || ph==${PH_LIQUID}) && loose(p))) { base=0.978; }
else if(ph==${PH_EMPTY}) { base=select(0.978,0.96,textureLoad(bgmT,p,0).r!=0u); }
else if(ph==${PH_GAS}) { base=0.96; }
else if(ph==${PH_LIQUID}) { base=0.93; }
else if(m==${GLASS}u || m==${ICE}u) { base=0.96; }
a=pow(base,u.world.z);
if(p.y<roofs[q.x]) { rgb=u.levels.x*vec3f(1,0.98,0.94); }
else if(f32(p.y)<columns[p.x].x && ph!=${PH_SOLID}) { rgb=u.levels.x*0.9*vec3f(1,0.98,0.95); }
var hottest=0.;
for(var dy=0;dy<res && p.y+dy<i32(u.world.y);dy++) {
for(var dx=0;dx<res && p.x+dx<i32(u.world.x);dx++) { hottest=max(hottest,textureLoad(tempT,p+vec2i(dx,dy),0).r); }
}
if(hottest>520.) {
let gi=u32(clamp(i32((hottest-400.)/4.),0,699)); let glow=lut[${NMAT*3}u+gi];
rgb+=glow.rgb/255.*glow.a*1.35;
}
}
if(u.lamp.w>0.) {
let d=distance(vec2f(q),u.lamp.xy)/u.lamp.z;
if(d<1.) { rgb=max(rgb,(1.-d)*(1.-d)*1.05*vec3f(1,0.93,0.8)); }
}
textureStore(dest,q,vec4f(rgb,a));
}`;
const GPU_LIGHT_SPREAD = `
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var dest: texture_storage_2d<rgba16float, write>;
@group(0) @binding(2) var<uniform> direction: vec4i;
@compute @workgroup_size(8,8)
fn main(@builtin(global_invocation_id) id: vec3u) {
let p=vec2i(id.xy); let size=vec2i(textureDimensions(source)); if(any(p>=size)) { return; }
let own=textureLoad(source,p,0); var rgb=own.rgb;
let x=p+vec2i(direction.x,0); let y=p+vec2i(0,direction.x);
if(all(x>=vec2i(0)) && all(x<size)) { rgb=max(rgb,textureLoad(source,x,0).rgb*own.a); }
if(all(y>=vec2i(0)) && all(y<size)) { rgb=max(rgb,textureLoad(source,y,0).rgb*own.a); }
textureStore(dest,p,vec4f(rgb,own.a));
}`;
const GPU_LIGHT_PACK = `
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var dest: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(2) var<uniform> u: array<vec4f,4>;
@compute @workgroup_size(8,8)
fn main(@builtin(global_invocation_id) id: vec3u) {
let p=vec2i(id.xy); if(any(p>=vec2i(textureDimensions(source)))) { return; }
let rgb=floor(clamp(max(textureLoad(source,p,0).rgb,vec3f(u[3].y)),vec3f(0),vec3f(1))*255.)/255.;
textureStore(dest,p,vec4f(rgb,1));
}`;
function gpuLightGrid() {
const res = SET.lighting === 'fast' ? 4 : 2, M = Math.ceil(28 / res);
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
const key = `${lw}/${lh}/${res}`;
const update = GPU.lightKey !== key || GPU.lightWorld !== mat || GPU.lightFrame === undefined || R.frame % SET.lightEvery === 0;
if (update) {
GPU.lightKey = key; GPU.lightWorld = mat; GPU.lightFrame = R.frame;
GPU.lightGrid = [Math.floor(R.vx0 / res) - M, Math.floor(R.vy0 / res) - M, lw, lh, res];
}
[R.lx0, R.ly0, R.lw, R.lh, R.lres] = GPU.lightGrid;
return update;
}
function gpuLightBindings(group, view, buffer) {
const d = GPU.device;
if (GPU.lightUniform) { GPU.lightUniform.destroy(); GPU.lightRoofs.destroy(); }
GPU.lightUniform = d.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
GPU.lightRoofs = d.createBuffer({ size: Math.max(1,R.lw) * 4, usage: GPUBufferUsage.STORAGE });
const shared = [buffer(GPU.lightUniform), buffer(GPU.lut), buffer(GPU.columns),
...[0,1,3,4].map(i => view(GPU.worldTextures[i])), view(GPU.mach), view(GPU.block), view(GPU.temp), buffer(GPU.lightRoofs)];
// Auto layouts contain only bindings used by each entry point.
GPU.bindRoof = d.createBindGroup({ layout: GPU.lightRoof.getBindGroupLayout(0), entries: shared.map((resource,binding)=>({binding,resource})).filter(e=>![6,9].includes(e.binding)) });
GPU.bindSources = group(GPU.lightSources, [...shared, view(GPU.lightWork[0])]);
if (!GPU.lightDirections) GPU.lightDirections = [-1,1].map(x => {
const b=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
d.queue.writeBuffer(b,0,new Int32Array([x,0,0,0])); return b;
});
GPU.bindSpread = GPU.lightDirections.map(b => [0,1].map(i => group(GPU.lightSpread, [view(GPU.lightWork[i]),view(GPU.lightWork[1-i]),buffer(b)])));
GPU.bindPack = group(GPU.lightPack, [view(GPU.lightWork[0]),view(GPU.lightTex),buffer(GPU.lightUniform)]);
GPU.lightFrame = undefined;
}
function gpuLight(encoder, timed) {
const q = GPU.device.queue, res = R.lres;
q.writeBuffer(GPU.lightUniform,0,new Float32Array([R.lx0,R.ly0,R.lw,R.lh,W,H,res,0,
(bot.x+bot.w/2)/res-R.lx0,(bot.y+bot.h/2)/res-R.ly0,34/res,+bot.lamp,
SET.daylight*(1-0.32*(R.rain||0)),SET.ambient,0,0]));
const pass = encoder.beginComputePass(timed ? {timestampWrites:{querySet:GPU.queries,beginningOfPassWriteIndex:2,endOfPassWriteIndex:3}} : {});
pass.setPipeline(GPU.lightRoof); pass.setBindGroup(0,GPU.bindRoof); pass.dispatchWorkgroups(Math.ceil(R.lw/64));
pass.setPipeline(GPU.lightSources); pass.setBindGroup(0,GPU.bindSources); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
pass.setPipeline(GPU.lightSpread);
// 320 grains per direction: even maximum emission falls below one packed level.
const count = 320/res;
for(let direction=0;direction<2;direction++) for(let i=0;i<count;i++) {
pass.setBindGroup(0,GPU.bindSpread[direction][i&1]); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
}
pass.setPipeline(GPU.lightPack); pass.setBindGroup(0,GPU.bindPack); pass.dispatchWorkgroups(Math.ceil(R.lw/8),Math.ceil(R.lh/8));
pass.end();
}
+42
View File
@@ -0,0 +1,42 @@
// Half-resolution separable Gaussian bloom, then nearest colour + linear light.
const GPU_BLUR = `
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var dest: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(2) var<uniform> blur: vec4f;
@compute @workgroup_size(8,8)
fn main(@builtin(global_invocation_id) id: vec3u) {
let p=vec2i(id.xy); let size=vec2i(textureDimensions(source)); if (any(p>=size)) { return; }
var sum=vec3f(0); var weight=0.; let sigma=blur.z;
let radius=i32(ceil(sigma*3.));
for (var j=-radius;j<=radius;j++) {
let w=exp(-f32(j*j)/(2.*sigma*sigma)); let q=p+vec2i(blur.xy)*j;
if (all(q>=vec2i(0)) && all(q<size)) { sum+=textureLoad(source,q,0).rgb*w; }
weight+=w;
}
textureStore(dest,p,vec4f(sum/weight,1));
}`;
const GPU_DRAW = `
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
@group(0) @binding(0) var<uniform> u: Params;
@group(0) @binding(1) var colour: texture_2d<f32>;
@group(0) @binding(2) var light: texture_2d<f32>;
@group(0) @binding(3) var glow: texture_2d<f32>;
@group(0) @binding(4) var linear: sampler;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
let pos=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3)); return vec4f(pos[i],0,1);
}
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
let cell=(p.xy-u.camera.xy)/u.camera.z;
var c=textureLoad(colour,vec2i(floor(cell)),0).rgb;
if (u.light.w>0.) {
let world=p.xy/u.camera.z+u.extra.xy;
let uv=(world/u.light.z-u.light.xy)/vec2f(textureDimensions(light));
let l=textureSampleLevel(light,linear,uv,0).rgb;
c=round(c*l*255.)/255.;
}
if (u.effects.w>0.) {
let uv=cell/(2.*vec2f(textureDimensions(glow)));
c+=textureSampleLevel(glow,linear,uv,0).rgb*u.screen.z;
}
return vec4f(c,1);
}`;
+368
View File
@@ -0,0 +1,368 @@
// Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
const GPU_SMOOTH = `
struct Params { view: vec4f, world: vec4f, style: vec4f, effects: vec4f, camera: vec4f, light: vec4f, screen: vec4f, extra: vec4f }
@group(0) @binding(0) var<uniform> u: Params;
@group(0) @binding(1) var tCol: texture_2d<f32>;
@group(0) @binding(2) var tBack: texture_2d<f32>;
@group(0) @binding(3) var tTree: texture_2d<f32>;
@group(0) @binding(4) var tField: texture_2d<f32>;
@group(0) @binding(5) var tLight: texture_2d<f32>;
@group(0) @binding(6) var tGlow: texture_2d<f32>;
@group(0) @binding(7) var linear: sampler;
@group(0) @binding(8) var<uniform> su: vec4f;
fn sampleLevel(t: texture_2d<f32>, uv: vec2f, level: f32) -> vec4f { return textureSampleLevel(t,linear,uv,level); }
fn modulo(x: f32,y: f32) -> f32 { return x-y*floor(x/y); }
fn h21(inputP: vec2f) -> f32 { var p=inputP; p = fract(p * vec2f(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
fn vn(p: vec2f) -> f32 {
var i: vec2f = floor(p); var f: vec2f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(h21(i), h21(i + vec2f(1, 0)), f.x), mix(h21(i + vec2f(0, 1)), h21(i + vec2f(1, 1)), f.x), f.y);
}
fn fbm(inputP: vec2f) -> f32 { var p=inputP; var a: f32 = 0.5; var s: f32 = 0.0; for (var k: i32 = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
fn lin(t: texture_2d<f32>, q: vec2f) -> vec4f { return sampleLevel(t, q / (u.view.zw), 0.0); }
fn bsp(t: texture_2d<f32>, q: vec2f) -> vec4f {
var tc: vec2f = q - 0.5; var i: vec2f = floor(tc); var f: vec2f = tc - i; var f2: vec2f = f * f; var f3: vec2f = f2 * f;
var w0: vec2f = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0; var w1: vec2f = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
var w2: vec2f = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0; var w3: vec2f = f3 / 6.0;
var g0: vec2f = w0 + w1; var g1: vec2f = w2 + w3;
var h0: vec2f = (i - 0.5 + w1 / g0) / (u.view.zw); var h1: vec2f = (i + 1.5 + w3 / g1) / (u.view.zw);
return g0.y * (g0.x * sampleLevel(t, h0, 0.0) + g1.x * sampleLevel(t, vec2f(h1.x, h0.y), 0.0))
+ g1.y * (g0.x * sampleLevel(t, vec2f(h0.x, h1.y), 0.0) + g1.x * sampleLevel(t, h1, 0.0));
}
fn cellAt(t: texture_2d<f32>, c: vec2i) -> vec4f { return textureLoad(t, clamp(c, vec2i(0), vec2i((u.view.zw)) - 1), 0); }
fn cls(c: vec4f) -> i32 { return i32(c.a * 255.0 + 0.5); }
fn isT(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ); }
fn isM(n: i32) -> f32 { return select( isT(n), 1.0 ,n == 2 ); }
fn isF(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 13 || n == 14) ); }
fn isBelt(n: i32) -> f32 { return select( 0.0, 1.0 ,(n == 7 || n == 8) ); }
fn isN(n: i32, k: i32) -> f32 { return select( 0.0, 1.0 ,n == k ); }
fn beltSD(lb: vec2f, endL: bool, endR: bool) -> f32 {
var x0: f32 = select( -2.0, 0.15 , endL ); var x1: f32 = select( 6.0, 3.85 , endR ); var r: f32 = 0.75;
var c: vec2f = vec2f((x0 + x1) * 0.5, 2.0); var hs: vec2f = vec2f((x1 - x0) * 0.5, 1.85);
var q: vec2f = abs(lb - c) - hs + r;
return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
}
fn beltLineSD(c: vec2i, lb: vec2f) -> f32 {
var caps: i32 = i32(cellAt(tTree, c).a * 255.0 + 0.5);
return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0);
}
fn bandRidge(w: vec2f, lb: vec2f, dir: f32) -> f32 {
var run: f32 = select( -1.0, 1.0 , lb.y < 2.0 );
return step(0.72, fract((w.x - dir * run * (su.x) * 30.0) * 0.5));
}
fn bandColor(lb: vec2f, rg: f32) -> vec3f { return mix(vec3f(0.2, 0.21, 0.24), vec3f(0.34, 0.35, 0.39), rg) * (select( 0.72, 1.0 ,lb.y < 2.0 )); }
${GPU_SMOOTH_SLOPE_HELPERS}
@fragment fn fs(@builtin(position) position: vec4f) -> @location(0) vec4f {
let uView=vec4f(u.extra.xy-u.view.xy,u.camera.z,u.screen.y);
let uLightMap=vec4f(u.light.xy*u.light.z-u.view.xy,u.light.z,u.light.w);
var zp: f32 = uView.z; var px: f32 = 1.0 / zp;
var sp: vec2f = vec2f(position.x, position.y);
var q: vec2f = sp * px + uView.xy;
var w: vec2f = q + (u.view.xy);
var cam: vec2f = (u.view.xy) + uView.xy;
var scr: vec2f = q - uView.xy;
var shc: f32 = uView.w * px;
var c: vec2i = vec2i(floor(q));
var qq: vec2f = q - 0.5; var c0: vec2i = vec2i(floor(qq)); var t: vec2f = qq - floor(qq);
var k0: vec4f = cellAt(tCol, c0); var k1: vec4f = cellAt(tCol, c0 + vec2i(1, 0)); var k2: vec4f = cellAt(tCol, c0 + vec2i(0, 1)); var k3: vec4f = cellAt(tCol, c0 + vec2i(1, 1));
var n0: i32 = cls(k0); var n1: i32 = cls(k1); var n2: i32 = cls(k2); var n3: i32 = cls(k3);
var own: vec4f = cellAt(tCol, c); var nc: i32 = cls(own);
var s: vec2f = smoothstep(vec2f(0.1), vec2f(0.9), t);
var wq: vec4f = vec4f((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
// Fields and screen derivatives before branching.
var Fb: vec4f = bsp(tField, q);
var F: vec4f = mix(lin(tField, q), Fb, 0.62);
var wl: vec4f = vec4f((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
var pw: f32 = dot(wl, vec4f(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
var M: f32 = clamp(F.r + pw, 0.0, 1.0);
var lf: f32 = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
var fwT: f32 = max(fwidth(F.r), 1e-4); var fwM: f32 = max(fwidth(M), 1e-4); var fwA: f32 = max(fwidth(F.a), 1e-4); var fwL: f32 = max(fwidth(lf), 1e-4);
var gT: vec2f = vec2f(dpdx(mix(Fb.r, M, 0.5)), -dpdy(mix(Fb.r, M, 0.5))); var gL: vec2f = vec2f(dpdx(F.g), -dpdy(F.g));
// Sky, clouds, parallax hills and back-wall AO.
var back: vec4f = lin(tBack, q);
var C: vec3f = back.rgb;
var sky: f32 = back.a;
if (sky > 0.01) {
var S: vec3f = C;
var P0: vec2f = scr + cam * 0.08;
var cl: f32 = smoothstep(0.56 - 0.3 * (u.style.y), 0.78 - 0.2 * (u.style.y), fbm(vec2f(P0.x * 0.016 + (su.x) * 0.006, P0.y * 0.05)));
var hi: f32 = clamp(((su.y) - 40.0 - w.y) / 50.0, 0.0, 1.0);
S = mix(S, mix(vec3f(0.96, 0.95, 0.93), vec3f(0.5, 0.52, 0.56), (u.style.y)), cl * (0.6 + 0.3 * (u.style.y)) * hi);
var k1: f32 = 0.16; var P1: vec2f = scr + cam * k1;
var base1: f32 = shc * 0.5 + 8.0 + ((su.y) - shc * 0.5) * k1;
var top1: f32 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2f(P1.x * 0.009, 1.7)));
var m1: f32 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
var far: vec3f = mix(vec3f(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
S = mix(S, far, m1);
var k2: f32 = 0.38; var P2: vec2f = scr + cam * k2;
var base2: f32 = shc * 0.5 + 14.0 + ((su.y) - shc * 0.5) * k2;
var top2: f32 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2f(P2.x * 0.016, 7.3)));
var m2: f32 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
var near: vec3f = vec3f(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
S = mix(S, near, m2);
C = mix(C, S, sky);
}
var notSky: f32 = 1.0 - sky;
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
var near: f32 = sampleLevel(tField, q / (u.view.zw), 2.2).r;
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
// Trees behind terrain and built pieces.
if (F.a > 0.2) {
var a0: vec4f = cellAt(tTree, c0); var a1: vec4f = cellAt(tTree, c0 + vec2i(1, 0)); var a2: vec4f = cellAt(tTree, c0 + vec2i(0, 1)); var a3: vec4f = cellAt(tTree, c0 + vec2i(1, 1));
var m: vec4f = vec4f(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
var sm: f32 = m.x + m.y + m.z + m.w;
if (sm > 1e-4) {
var tc: vec3f = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
var leaf: f32 = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
var lv: f32 = smoothstep(0.6, 0.9, leaf);
tc *= mix(0.86 + 0.22 * vn(vec2f(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
var d: f32 = (F.a - 0.36) / fwA;
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
}
}
// Smoke, steam and flame.
if (Fb.b > 0.02) {
var m: vec4f = vec4f(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
var sm: f32 = m.x + m.y + m.z + m.w;
var gc: vec3f = select( own.rgb, (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm , sm > 1e-4 );
if (sm > 1e-4 || nc == 5) { C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92); }
}
// Liquids with a glossy top edge.
var anyL: f32 = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
if (anyL > 0.0 && lf > 0.3) {
var m: vec4f = vec4f(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
var sm: f32 = m.x + m.y + m.z + m.w;
var lc: vec3f = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2f((su.x) * 0.7, (su.x) * 0.25));
var gl: f32 = length(gL);
var topSide: f32 = select( 0.0, clamp(gL.y / gl, 0.0, 1.0) , gl > 1e-5 );
var band: f32 = 1.0 - smoothstep(0.5, 0.75, F.g);
lc += vec3f(0.16, 0.18, 0.2) * topSide * band;
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
}
// Rounded ground and the seam against loose grains.
if (F.r > 0.3) {
var m: vec4f = vec4f(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
var sm: f32 = m.x + m.y + m.z + m.w;
var blur: vec3f = bsp(tCol, q).rgb; var tc: vec3f = own.rgb;
var bd: f32 = 1e9;
if (m.x > 0.0) { var e: vec3f = k0.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
if (m.y > 0.0) { var e: vec3f = k1.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
if (m.z > 0.0) { var e: vec3f = k2.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
if (m.w > 0.0) { var e: vec3f = k3.rgb - blur; var dd: f32 = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
if (sm < 1e-4) { tc = own.rgb; }
tc *= 0.84 + 0.3 * fbm(w * 0.28);
tc *= 0.95 + 0.1 * vn(w * 1.6);
var gl: f32 = length(gT);
var nrm: vec2f = select( vec2f(0.0), -gT / gl , gl > 1e-6 );
var band: f32 = 1.0 - smoothstep(0.5, 0.9, M);
var lit: f32 = dot(nrm, normalize(vec2f(-0.5, -1.0)));
tc *= 1.0 + 0.42 * lit * band;
var dm: f32 = (M - 0.5) / fwM;
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
var dt: f32 = (F.r - 0.5) / fwT;
if (nc == 1 && pw > 0.0) { tc *= mix(0.65, 1.0, smoothstep(0.0, 1.5, dt)); }
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
}
${GPU_SMOOTH_SLOPE_DRAW}
// Round loose grains.
var np: i32 = i32(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
var closed: bool = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
if (np > 0) {
var best: f32 = 1e9; var gc: vec3f = vec3f(0.0); var gd: vec2f = vec2f(0.0); var gr: f32 = 0.6;
for (var k: i32 = 0; k < 4; k++) {
var o4: vec2i = vec2i(k & 1, k >> 1);
var nk: i32 = select(select(select( n3, n2 , k == 2 ), n1 , k == 1 ), n0 , k == 0 );
if (nk != 2) { continue; }
var ck: vec4f = select(select(select( k3, k2 , k == 2 ), k1 , k == 1 ), k0 , k == 0 );
var ce: vec2f = vec2f(c0 + o4) + 0.5;
var r: f32 = 0.6 + 0.1 * (h21(ce + (u.view.xy)) - 0.5);
var dv: vec2f = (q - ce) / r;
var dd: f32 = length(dv);
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
}
var cover: f32 = select( 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best), 1.0 , np == 4 );
if (closed && F.r < 0.5) { cover = 1.0; }
cover *= 1.0 - smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT);
if (cover > 0.0) {
var r2: f32 = min(1.0, dot(gd, gd));
var n3: vec3f = normalize(vec3f(gd * 0.85, sqrt(1.0 - r2) + 0.25));
var sh: f32 = 0.98 + 0.18 * max(0.0, dot(n3, normalize(vec3f(-0.45, -0.8, 0.6))));
sh *= mix(1.0, 0.94, smoothstep(0.7 - px * 0.2, 1.15 + px * 0.2, best));
var pc: vec3f = (select( gc, (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) ,np == 4 )) * sh;
if (!closed) { pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best)); }
C = mix(C, pc, cover);
}
}
// Built pieces: belts, screws, wall tiles and filters.
if (nc == 4) { C = own.rgb; }
else if (nc == 7 || nc == 8) {
var dir: f32 = select( -1.0, 1.0 , nc == 8 );
var lb: vec2f = fract(w * 0.25) * 4.0;
var sd: f32 = beltLineSD(c, lb);
var inside: f32 = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
var edge: f32 = -sd;
var bc: vec3f;
if (edge < 0.8) {
bc = bandColor(lb, bandRidge(w, lb, dir));
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
var rc: vec2f = vec2f(select( 3.0, 1.0 ,lb.x < 2.0 ), 2.0); var dv: vec2f = lb - rc;
var rd: f32 = length(dv);
bc = vec3f(0.15, 0.16, 0.18);
if (rd < 0.95) {
var ang: f32 = atan2(dv.y, dv.x) - dir * (su.x) * 30.0 / 0.95;
var spoke: f32 = smoothstep(0.55, 0.85, cos(ang * 3.0));
bc = mix(vec3f(0.5, 0.36, 0.25), vec3f(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2f(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
}
}
C = mix(C, bc, inside);
}
}
else if ((nc >= 9 && nc <= 12) || (nc >= 19 && nc <= 22)) {
var vert: bool = nc == 9 || nc == 11;
var sp2: f32 = select(select(select( -1.0, 1.0 , nc == 11 ), 1.0 , nc == 10 ), -1.0 , nc == 9 );
var a: f32 = (select( fract(w.y * 0.25), fract(w.x * 0.25) ,vert )) * 4.0; var along: f32 = select( w.x, w.y , vert );
var screwSD: f32 = -1.0; var capRing: f32 = 0.0;
var screwTag: vec4f = cellAt(tTree, c);
var screwJoins: i32 = i32(screwTag.a * 255.0 + 0.5);
var shaped: bool = nc >= 19 || (screwJoins > 0 && screwJoins < 64);
if (shaped) {
var d: vec2f = vec2f(1.0,select( 1.0, -1.0 , nc == 19 || nc == 21 ));
var tag: vec4f = screwTag;
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w) - 2.0;
var joins: i32 = screwJoins;
screwSD = 100.0;
for (var end: i32 = 0; end < 2; end++) {
var join: i32 = (joins >> u32(end * 3)) & 7;
var side: f32 = select( 1.0, -1.0 , end == 0 );
var diagonal: bool = select( join != 0, join == 0 || join >= 5 , nc >= 19 );
var grade: f32 = select(select( 1.0, -1.0 , join == 1 ), d.y , nc >= 19 );
var ray: vec2f = select(select(select(select(select( vec2f(-1, 0), vec2f(0, 1) , join == 3 ), vec2f(1, 0) , join == 2 ), vec2f(0, -1) ,
join == 1 ), (select( vec2f(side, 0), vec2f(0, side) ,vert )) ,
nc < 19 ), (select( vec2f(side, side * grade), vec2f(side * grade, side) ,vert )) * 0.70710678 , diagonal );
var t: f32 = dot(local, ray); var cross2: f32 = dot(local, vec2f(-ray.y, ray.x));
var radius: f32 = length(vec2f(cross2, min(0.0, t))); var cap: f32 = t - (select( 2.0, 2.82842712 ,diagonal ));
var sd: f32 = max(radius - 2.0,select( -10.0, cap , (joins & (64 << u32(end))) != 0 ));
if (sd < screwSD) {
screwSD = sd;
a = 2.0 + (select( radius, -radius ,side * cross2 < 0.0 ));
along = (select(select( w.x - local.x, w.y - local.y , vert ), dot(w - local, d) * 0.70710678 ,nc >= 19 )) + side * t;
capRing = select( 0.0, smoothstep(-0.55, -0.15, cap) ,(joins & (64 << u32(end))) != 0 );
}
}
if (nc >= 19) { sp2 = select( -1.0, 1.0 ,nc == 19 || nc == 20 ); }
}
var wall: f32 = 0.62;
var tc: vec3f;
if (a < wall || a > 4.0 - wall || capRing > 0.5) {
var u2: f32 = select( (4.0 - a) / wall, a / wall , a < wall );
var cyl: f32 = sin(clamp(u2, 0.0, 1.0) * 1.5708);
tc = vec3f(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
if (a < wall) { tc *= 1.08; }
var ring: f32 = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
if (nc < 19) { tc *= 1.0 - 0.3 * ring; }
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
} else {
var full: bool = own.r + own.g + own.b > 0.0;
var fl: f32 = fract((along + a * 0.9 - sp2 * (su.x) * 14.0) * 0.5);
var flight: f32 = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
var bore: vec3f = vec3f(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
tc = select( bore, own.rgb * (0.9 + 0.1 * vn(w * 1.3)) ,full );
tc = mix(tc, vec3f(0.6, 0.48, 0.38), flight * (select( 0.8, 0.35 ,full )));
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
}
if (shaped) {
var full: bool = own.r + own.g + own.b > 0.0;
if (full) { C = mix(C, own.rgb, 1.0 - smoothstep(0.36, 0.5, length(fract(w) - 0.5))); }
C = mix(C, tc, 1.0 - smoothstep(2.0 - px, 2.0 + px, screwSD + 2.0));
} else { C = tc; }
}
else if (nc == 6 || (nc >= 15 && nc <= 18)) {
var lb: vec2f = fract(w * 0.25) * 4.0; var rb: vec2f = 4.0 - lb;
var e: f32 = min(min(lb.x, lb.y), min(rb.x, rb.y));
var tc: vec3f = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
var hl: f32 = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
var shd: f32 = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
if (nc >= 15) {
var sl: vec2f = lb;
if (nc == 16 || nc == 18) { sl.x = 4.0 - sl.x; }
if (nc >= 17) { sl.y = 4.0 - sl.y; }
var d: f32 = (sl.y - sl.x) * 0.70710678;
tc *= 1.0 + 0.2 * (1.0 - smoothstep(0.0, 0.6, d));
C = mix(C, tc, smoothstep(-px * 0.5, px * 0.5, d));
} else { C = tc; }
}
else if (nc == 13 || nc == 14) {
var dir: f32 = select( -1.0, 1.0 , nc == 14 );
var lb: vec2f = fract(w * 0.25) * 4.0;
var tag: vec4f = cellAt(tTree, c);
var carry: bool = tag.a > 0.16; var tinted: bool = tag.r + tag.g + tag.b > 0.0;
var sd: f32 = beltLineSD(c, lb);
var inside: f32 = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
var edge: f32 = -sd;
var steel: vec3f = vec3f(0.42, 0.44, 0.5);
var behind: vec3f = select( C * 0.8, own.rgb , own.r + own.g + own.b > 0.0 );
var cg: vec2f = abs(fract(w) - 0.5);
var bw2: f32 = max(px * 0.9, 0.09);
var hole: f32 = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
var fc: vec3f;
if (edge < 0.8) {
var rg: f32 = bandRidge(w, lb, dir);
var band: vec3f = bandColor(lb, rg);
if (tinted && (carry == (lb.y < 2.0))) { band = mix(band, tag.rgb * (select( 0.8, 1.0 ,lb.y < 2.0 )), 0.75 * rg); }
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
fc = mix(steel * 0.78, behind, hole);
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
}
C = mix(C, fc, inside);
}
}
// Light, then the legacy nine-tap glow filter.
if (uLightMap.w > 0.5) { C *= sampleLevel(tLight, (q - uLightMap.xy) / uLightMap.z / (vec2f(textureDimensions(tLight))), 0.0).rgb; }
if ((su.z) > 0.0) {
var gu: vec2f = q / (2.0 * (vec2f(textureDimensions(tGlow)))); var d: vec2f = 1.0 / (vec2f(textureDimensions(tGlow)));
var g: vec3f = sampleLevel(tGlow, gu, 0.0).rgb * 0.25;
g += (sampleLevel(tGlow, gu + vec2f(d.x, 0), 0.0).rgb + sampleLevel(tGlow, gu - vec2f(d.x, 0), 0.0).rgb + sampleLevel(tGlow, gu + vec2f(0, d.y), 0.0).rgb + sampleLevel(tGlow, gu - vec2f(0, d.y), 0.0).rgb) * 0.125;
g += (sampleLevel(tGlow, gu + d, 0.0).rgb + sampleLevel(tGlow, gu - d, 0.0).rgb + sampleLevel(tGlow, gu + vec2f(d.x, -d.y), 0.0).rgb + sampleLevel(tGlow, gu + vec2f(-d.x, d.y), 0.0).rgb) * 0.0625;
C += g * (su.z);
}
return vec4f(C, 1.0);
}`;
+107
View File
@@ -0,0 +1,107 @@
// Smooth slope geometry and cleats, ported from slope-belt-shader.js.
const GPU_SMOOTH_SLOPE_HELPERS = `fn slopeStroke(p: vec2f, a: vec2f, b: vec2f, origin: f32) -> vec3f {
var ray: vec2f = b - a; var len: f32 = length(ray); ray /= len;
var along: f32 = dot(p - a, ray); var across: f32 = dot(p - a, vec2f(-ray.y, ray.x));
var q: vec2f = abs(vec2f(along - len * 0.5, across)) - vec2f(len * 0.5 - 0.75, 1.1);
var sd: f32 = length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - 0.75;
return vec3f(sd, origin + along, across + 2.0);
}
fn slopeCorner(p: vec2f, a: vec2f, origin: f32) -> vec3f {
var d: vec2f = p - a; var radius: f32 = length(d);
return vec3f(radius - 1.85, origin + d.x, 2.0 + d.y);
}
fn slopeShape(inputP: vec2f, worldOrigin: vec2f, grade: f32, joins: i32) -> vec3f { var p=inputP;
if ((joins & 64) != 0) { p.y -= 0.45; }
var sy: f32 = select( 0.0, 4.0 , grade < 0.0 );
var a: vec2f = vec2f(-grade * 1.30814755, sy + 1.30814755); var b: vec2f = vec2f(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755);
if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; }
else if ((joins & 4) != 0) { a -= vec2f(4.0, grade * 4.0); }
if ((joins & 2) != 0) { b.y = 6.0 - sy; b.x = (b.y - sy - 2.61629509) / grade; }
else if ((joins & 8) != 0) { b += vec2f(4.0, grade * 4.0); }
var axis: vec2f = vec2f(1.0, grade) * 0.70710678;
var best: vec3f = slopeStroke(p, a, b, dot(worldOrigin + a, axis));
if ((joins & 1) != 0) {
var h: vec3f = slopeStroke(p, vec2f(-2.0, a.y), a, worldOrigin.x - 2.0);
if (h.x < best.x) { best = h; }
h = slopeCorner(p, a, worldOrigin.x + a.x);
if (h.x < best.x) { best = h; }
}
if ((joins & 2) != 0) {
var h: vec3f = slopeStroke(p, b, vec2f(6, b.y), worldOrigin.x + b.x);
if (h.x < best.x) { best = h; }
h = slopeCorner(p, b, worldOrigin.x + b.x);
if (h.x < best.x) { best = h; }
}
return best;
}
`;
const GPU_SMOOTH_SLOPE_DRAW = ` if (nc == 31 || nc == 32) {
var tag: vec4f = cellAt(tTree, c);
var grade: f32 = select( 1.0, -1.0 , nc == 31 );
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
var sy: f32 = select( 0.0, 4.0 , grade < 0.0 );
var foot: f32 = (local.x + grade * (local.y - sy)) * 0.5;
var depth: f32 = (grade * local.x - local.y + sy) * 0.70710678;
var wallFlags: i32 = i32(tag.a * 255.0 + 0.5);
var ends: f32 = max(select( -100.0, -foot ,(wallFlags & 8) == 0 ),select( -100.0, foot - 4.0 , (wallFlags & 16) == 0 ));
var sd: f32 = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)}));
var phase: f32 = f32(wallFlags & 7);
var wave: f32 = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265);
var rubber: vec3f = mix(vec3f(0.20,0.22,0.25), vec3f(0.25,0.27,0.31), wave);
C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd));
var dirS: f32 = select( -1.0, 1.0 , (wallFlags & 32) != 0 );
if (grade * dirS < 0.0) {
var rc: f32 = (w.x - local.x) + foot - (select( 0.0, 1.0 ,grade < 0.0 )) - dirS * phase;
var off: f32 = (modulo(rc + ${(CLEAT_SPACING / 2).toFixed(1)}, ${CLEAT_SPACING.toFixed(1)}) - ${(CLEAT_SPACING / 2).toFixed(1)}) * 1.41421356;
var ribTop: f32 = ${((2 * CLEAT_REACH + 1) * Math.SQRT1_2).toFixed(3)};
var rd: f32 = length(vec2f(off, depth - clamp(depth, 0.0, ribTop))) - 0.5;
var rib: vec3f = mix(vec3f(0.50,0.52,0.56), vec3f(0.70,0.72,0.76), smoothstep(ribTop - 1.2, ribTop, depth));
rib *= mix(0.78, 1.0, smoothstep(-0.5, 0.2, off * -dirS * grade));
C = mix(C, rib, 1.0 - smoothstep(-px, px, rd));
}
}
if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
(i32(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 &&
cellAt(tTree, c).r * 255.0 >= 22.5 && cellAt(tTree, c).r * 255.0 <= 30.5) {
var slopeClass: i32 = i32(cellAt(tTree, c).r * 255.0 + 0.5);
var shape: i32 = (slopeClass - 23) / 2;
var mesh: bool = shape >= 2;
var grade: f32 = select( 1.0, -1.0 , (shape == 0 || shape == 2) );
var dir: f32 = select( -1.0, 1.0 , ((slopeClass - 23) % 2) == 1 );
var tag: vec4f = cellAt(tTree, c);
var joins: i32 = i32(tag.a * 255.0 + 0.5);
var local: vec2f = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
var shapePos: vec3f = slopeShape(local, w - local, grade, joins);
var sd: f32 = shapePos.x; var axis: f32 = shapePos.y; var depth: f32 = shapePos.z;
var time: f32 = select( 0.0, (su.x) , !mesh && (joins & 16) != 0 );
var lb: vec2f = vec2f(axis, depth); var alongWorld: vec2f = vec2f(axis, depth);
var rg: f32 = step(0.72, fract((axis - dir * (select( -1.0, 1.0 ,depth < 2.0 )) * time * 30.0) * 0.5));
var belt: vec3f;
if (-sd < select(0.8,1.1,mesh)) {
belt = select( bandColor(lb, rg), mix(vec3f(0.22,0.25,0.29), vec3f(0.48,0.53,0.59),select( 0.35, 0.85 , depth < 2.0 )) ,mesh );
belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
} else if (mesh) {
var cg: vec2f = abs(fract(alongWorld) - 0.5);
var bw2: f32 = max(px * 0.9, 0.09);
var hole: f32 = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
var steel: vec3f = select( vec3f(0.42, 0.44, 0.5), mix(vec3f(0.42, 0.44, 0.5), own.rgb, 0.4) , dot(own.rgb, vec3f(1)) > 0.01 );
belt = mix(steel * 0.78, C * 0.8, hole);
} else {
var dv: vec2f = vec2f(modulo(axis, 2.0) - 1.0, depth - 2.0);
var rd: f32 = length(dv);
belt = vec3f(0.15, 0.16, 0.18);
if (rd < 0.95) {
var ang: f32 = atan2(dv.y, dv.x) - dir * time * 30.0 / 0.95;
var spoke: f32 = smoothstep(0.55, 0.85, cos(ang * 3.0));
belt = mix(vec3f(0.5, 0.36, 0.25), vec3f(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2f(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
belt *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
belt *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
}
}
C = mix(C, belt, 1.0 - smoothstep(-px, px, sd));
}
`;
+48
View File
@@ -0,0 +1,48 @@
// Field mip levels preserve the legacy trilinear AO sample at level 2.2.
const GPU_FIELD_MIP = `
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var linear: sampler;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
let pos=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3)); return vec4f(pos[i],0,1);
}
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
let size=max(vec2u(1),textureDimensions(source)/2u);
return textureSampleLevel(source,linear,p.xy/vec2f(size),0);
}`;
async function gpuSmoothInit() {
const d=GPU.device;
const pipeline=(code,vertex,format)=>d.createRenderPipelineAsync({layout:'auto',vertex:{module:d.createShaderModule({code:vertex}),entryPoint:'vs'},fragment:{module:d.createShaderModule({code}),entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}});
GPU.smooth=await pipeline(GPU_SMOOTH,GPU_DRAW,GPU.format);
GPU.fieldMip=await pipeline(GPU_FIELD_MIP,GPU_FIELD_MIP,'rgba8unorm');
GPU.smoothUniform=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
}
function gpuSmoothTextures() {
const w=R.bw,h=R.bh;
GPU.smoothOver=Array.from({length:3},()=>gpuTexture(w,h));
GPU.smoothMask=gpuTexture(w,h,'r8uint');
GPU.backTex=gpuTexture(w,h,'rgba8unorm',true);
GPU.treeTex=gpuTexture(w,h,'rgba8unorm',true);
GPU.fieldTex=GPU.device.createTexture({size:[w,h],format:'rgba8unorm',mipLevelCount:Math.min(4,1+Math.floor(Math.log2(Math.max(w,h)))),usage:GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.STORAGE_BINDING|GPUTextureUsage.RENDER_ATTACHMENT});
GPU.viewTextures.push(...GPU.smoothOver,GPU.smoothMask,GPU.backTex,GPU.treeTex,GPU.fieldTex);
}
function gpuSmoothBindings(group,view,buffer) {
const colourResources=[buffer(GPU.uniform),buffer(GPU.lut),buffer(GPU.land),...GPU.worldTextures.slice(0,9).map(view),view(GPU.override),view(GPU.overrideGlow),view(GPU.colourTex),null,view(GPU.backTex),view(GPU.treeTex),GPU.fieldTex.createView({baseMipLevel:0,mipLevelCount:1}),...GPU.smoothOver.map(view),view(GPU.smoothMask)];
GPU.bindColourSmooth=GPU.device.createBindGroup({layout:GPU.colourSmooth.getBindGroupLayout(0),entries:colourResources.map((resource,binding)=>({binding,resource})).filter(e=>e.resource)});
GPU.bindSmooth=group(GPU.smooth,[buffer(GPU.uniform),view(GPU.colourTex),view(GPU.backTex),view(GPU.treeTex),view(GPU.fieldTex),view(GPU.lightTex),view(GPU.glowTex),GPU.sampler,buffer(GPU.smoothUniform)]);
GPU.fieldMips=[];
for(let i=1;i<GPU.fieldTex.mipLevelCount;i++) GPU.fieldMips.push({
bind:group(GPU.fieldMip,[GPU.fieldTex.createView({baseMipLevel:i-1,mipLevelCount:1}),GPU.sampler]),
view:GPU.fieldTex.createView({baseMipLevel:i,mipLevelCount:1})
});
}
function gpuSmoothUpload() {
for(const [i,a] of [R.backPx,R.treePx,R.fieldPx].entries()) gpuWrite(GPU.smoothOver[i],a,R.bw,0,0,R.bw,R.bh);
gpuWrite(GPU.smoothMask,GPU.overrideMask,R.bw,0,0,R.bw,R.bh);
GPU.device.queue.writeBuffer(GPU.smoothUniform,0,new Float32Array([(performance.now()-SM.t0)/1000,smoothSurface(),SET.glow==='off'?0:SET.glow==='high'?0.9:0.55,0]));
}
function gpuSmoothMips(encoder) {
for(const m of GPU.fieldMips) {
const pass=encoder.beginRenderPass({colorAttachments:[{view:m.view,loadOp:'clear',storeOp:'store'}]});
pass.setPipeline(GPU.fieldMip);pass.setBindGroup(0,m.bind);pass.draw(3);pass.end();
}
}
+5
View File
@@ -0,0 +1,5 @@
const SM = { t0: performance.now(), surf: -1 };
function smoothSurface() {
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; }
return SM.surf;
}
+98
View File
@@ -0,0 +1,98 @@
// Whole-world textures retain clean chunks across camera moves.
function gpuTexture(w, h, format = 'rgba8unorm', storage = false) {
return GPU.device.createTexture({ size: [w, h], format, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | (storage ? GPUTextureUsage.STORAGE_BINDING : 0) });
}
function gpuWrite(texture, data, stride, x, y, w, h) {
if (w <= 0 || h <= 0) return;
const bytes = data.BYTES_PER_ELEMENT;
GPU.device.queue.writeTexture({ texture, origin: [x, y] }, data, { offset: (y * stride + x) * bytes, bytesPerRow: stride * bytes }, [w, h]);
}
function gpuBuffer(data) {
const b = GPU.device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
GPU.device.queue.writeBuffer(b, 0, data); return b;
}
function gpuWorldTextures() {
if (GPU.world === mat && GPU.sky === SKY_R) return;
if (GPU.worldTextures) for (const t of GPU.worldTextures) t.destroy();
if (GPU.lut) GPU.lut.destroy(); if (GPU.land) GPU.land.destroy();
GPU.world = mat; GPU.sky = SKY_R;
GPU.resident = new Uint8Array(NCH);
GPU.worldTextures = Array.from({ length: 8 }, (_, i) => gpuTexture(i === 7 ? FW : W, i === 7 ? FH : H, 'r8uint'));
GPU.temp = gpuTexture(W, H, 'r32float'); GPU.worldTextures.push(GPU.temp);
gpuWrite(GPU.worldTextures[6], noiseB, W, 0, 0, W, H);
const tables = new Float32Array((NMAT * 3 + 700 + 551 * 3 + 3 * 512) * 4);
for (let m = 0; m < NMAT; m++) {
tables.set([COLR[m], COLG[m], COLB[m], CVAR[m], PH[m], ALPHA[m], 0, 0, TREE_R[m], TREE_G[m], TREE_B[m], 0], m * 12);
}
for (let i = 0; i < 700; i++) tables.set([GLOW_R[i], GLOW_G[i], GLOW_B[i], GLOW_W[i]], (NMAT * 3 + i) * 4);
for (const [j, palette] of Object.values(THERM).entries()) for (let i = 0; i < 551; i++) tables.set([palette[i * 3], palette[i * 3 + 1], palette[i * 3 + 2], 0], (NMAT * 3 + 700 + j * 551 + i) * 4);
tables.set(gpuCrackTables(), (NMAT * 3 + 700 + 551 * 3) * 4);
GPU.lut = gpuBuffer(tables);
GPU.landData = new Float32Array((W + H) * 4);
GPU.land = gpuBuffer(GPU.landData);
if (GPU.columns) GPU.columns.destroy();
GPU.columnData = new Float32Array(W * 4); GPU.columns = gpuBuffer(GPU.columnData);
GPU.mach = gpuTexture(W, H, 'r16uint'); GPU.block = gpuTexture(BW, BH, 'r8uint');
GPU.worldTextures.push(GPU.mach, GPU.block);
GPU.bindColour = null;
}
function gpuViewTextures() {
const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}/${+R.smoothOn}/${R.canvas.width}/${R.canvas.height}`;
if (GPU.viewKey === key) return;
GPU.viewKey = key; GPU.lightFrame = undefined;
if (GPU.viewTextures) for (const t of GPU.viewTextures) t.destroy();
const bw = R.bw, bh = R.bh, gw = R.glowW, gh = R.glowH;
GPU.crackTex = gpuTexture(R.canvas.width, R.canvas.height, 'rgba8unorm', true);
GPU.override = gpuTexture(bw, bh); GPU.overrideGlow = gpuTexture(gw, gh);
GPU.colourTex = gpuTexture(bw, bh, 'rgba8unorm', true);
GPU.glowTex = gpuTexture(gw, gh, 'rgba8unorm', true);
GPU.blurX = gpuTexture(gw, gh, 'rgba8unorm', true); GPU.blurY = gpuTexture(gw, gh, 'rgba8unorm', true);
GPU.lightTex = gpuTexture(Math.max(1, R.lw), Math.max(1, R.lh), 'rgba8unorm', true);
GPU.lightWork = [0,1].map(() => gpuTexture(Math.max(1,R.lw),Math.max(1,R.lh),'rgba16float',true));
GPU.viewTextures = [GPU.crackTex, GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex, ...GPU.lightWork];
if (R.smoothOn) gpuSmoothTextures();
GPU.bindColour = null;
}
function gpuBindings() {
if (GPU.bindColour) return;
const d = GPU.device, buffer = b => ({ buffer: b }), view = t => t.createView();
const group = (pipeline, resources) => d.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: resources.map((resource, binding) => ({ binding, resource })) });
GPU.bindColour = group(GPU.colour, [buffer(GPU.uniform), buffer(GPU.lut), buffer(GPU.land), ...GPU.worldTextures.slice(0,9).map(view), view(GPU.override), view(GPU.overrideGlow), view(GPU.colourTex), view(GPU.glowTex)]);
if (R.smoothOn) gpuSmoothBindings(group, view, buffer);
gpuLightBindings(group, view, buffer);
GPU.bindCracks = group(GPU.cracks, [buffer(GPU.uniform),buffer(GPU.lut),view(GPU.worldTextures[0]),view(GPU.worldTextures[5]),view(GPU.crackTex)]);
GPU.bindCrackDraw = group(GPU.crackDraw, [view(GPU.crackTex)]);
GPU.bindDraw = group(GPU.draw, [buffer(GPU.uniform), view(GPU.colourTex), view(GPU.lightTex), view(GPU.blurY), GPU.sampler]);
if (GPU.blurUniforms) for (const b of GPU.blurUniforms) b.destroy();
GPU.blurUniforms = [0, 1].map(() => d.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }));
GPU.bindBlur = [group(GPU.blur, [view(GPU.glowTex), view(GPU.blurX), buffer(GPU.blurUniforms[0])]), group(GPU.blur, [view(GPU.blurX), view(GPU.blurY), buffer(GPU.blurUniforms[1])])];
}
function gpuUpload(lighting, glowOn) {
gpuWorldTextures(); gpuViewTextures(); gpuBindings();
const margin = lighting ? Math.ceil(28 / R.lres) * R.lres + R.lres : 2;
const x = Math.max(0, R.vx0 - margin), y = Math.max(0, R.vy0 - margin);
const w = Math.min(W, R.vx0 + R.bw + margin) - x, h = Math.min(H, R.vy0 + R.bh + margin + 12) - y;
let top = y;
if (lighting) for (let xx = x; xx < x + w; xx++) top = Math.min(top, roofY[xx]);
gpuUploadChunks(x, top, x + w, y + h);
// Small per-row/column tables may change in scene tools independently of world allocation.
const land = GPU.landData;
for (let i = 0; i < W; i++) { const k = i * 4; land[k] = surfY[i]; land[k + 1] = dirtY[i]; land[k + 2] = graniteY[i]; land[k + 3] = basaltY[i]; }
for (let i = 0; i < H; i++) { const k = (W + i) * 4; land[k] = SKY_R[i]; land[k + 1] = SKY_G[i]; land[k + 2] = SKY_B[i]; land[k + 3] = AMB[i]; }
const q = GPU.device.queue;
q.writeBuffer(GPU.land, 0, land);
gpuWrite(GPU.override, R.px, R.bw, 0, 0, R.bw, R.bh);
gpuWrite(GPU.overrideGlow, R.glowPx, R.glowW, 0, 0, R.glowW, R.glowH);
for(let i=0;i<W;i++) { GPU.columnData[i*4]=surfY[i]; GPU.columnData[i*4+1]=roofY[i]; }
q.writeBuffer(GPU.columns,0,GPU.columnData);
if (R.smoothOn) gpuSmoothUpload();
const zp = R.zp, ox = Math.round(-(R.camX - R.vx0) * zp), oy = Math.round(-(R.camY - R.vy0) * zp);
GPU.params.set([R.vx0, R.vy0, R.bw, R.bh, W, H, FW, FH,
+SET.fog, R.rain, SET.grain, R.frame, +R.lens, Math.max(0, Object.keys(THERM).indexOf(SET.thermalPalette)), +SET.incandescence, +glowOn,
ox, oy, zp, 0, R.lx0, R.ly0, R.lres, +lighting,
R.canvas.width, R.canvas.height, SET.glow === 'high' ? 0.9 : 0.5, 0, R.camX, R.camY, 0, 0]);
q.writeBuffer(GPU.uniform, 0, GPU.params);
const sigma = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2)) / (2 * zp);
q.writeBuffer(GPU.blurUniforms[0], 0, new Float32Array([1, 0, sigma, 0]));
q.writeBuffer(GPU.blurUniforms[1], 0, new Float32Array([0, 1, sigma, 0]));
}
+48
View File
@@ -0,0 +1,48 @@
// Upload full chunks so scrolling within a resident chunk needs no extra copy.
function gpuUploadChunks(x0, y0, x1, y1) {
const cx0 = Math.max(0, x0 >> 5), cy0 = Math.max(0, y0 >> 5);
const cx1 = Math.min(CW, Math.ceil(x1 / 32)), cy1 = Math.min(CH, Math.ceil(y1 / 32));
GPU.uploadStrips = 0; GPU.uploadCells = 0;
for (let cy = cy0; cy < cy1; cy++) for (let cx = cx0; cx < cx1;) {
const c = cy * CW + cx;
if (!renderDirty[c] && GPU.resident[c]) { cx++; continue; }
const first = cx++;
while (cx < cx1 && (renderDirty[cy * CW + cx] || !GPU.resident[cy * CW + cx])) cx++;
const x = first * 32, y = cy * 32, w = Math.min(W, cx * 32) - x, h = Math.min(32, H - y);
gpuUploadStrip(x, y, w, h);
GPU.uploadStrips++; GPU.uploadCells += w * h;
for (let k = first; k < cx; k++) { renderDirty[cy * CW + k] = 0; GPU.resident[cy * CW + k] = 1; }
}
}
function gpuUploadStrip(x, y, w, h) {
for (const [i, a] of [mat, flags, life, part, bgm, digp].entries()) gpuWrite(GPU.worldTextures[i], a, W, x, y, w, h);
gpuWrite(GPU.temp, temp, W, x, y, w, h);
gpuWrite(GPU.mach, mach, W, x, y, w, h);
gpuWrite(GPU.block, blockP, BW, x >> 2, y >> 2, Math.ceil(w / BS), Math.ceil(h / BS));
// Fog interpolation reads the next sample, possibly in a neighbouring chunk.
gpuWrite(GPU.worldTextures[7], fog, FW, x / FOG, y / FOG, Math.min(FW - x / FOG, Math.ceil(w / FOG) + 1), Math.min(FH - y / FOG, Math.ceil(h / FOG) + 1));
}
// Debug-only readback: compares resident GPU texels to CPU arrays without refreshing them.
async function gpuCheckUploads() {
const sources = [...[mat, flags, life, part, bgm, digp].map((a,i)=>[GPU.worldTextures[i],a,W,1]),
[GPU.temp,temp,W,1], [GPU.mach,mach,W,1], [GPU.block,blockP,BW,BS], [GPU.worldTextures[7],fog,FW,FOG]];
let checked = 0;
for (const [texture, data, stride, scale] of sources) {
const bytes = data.BYTES_PER_ELEMENT, row = Math.ceil(texture.width * bytes / 256) * 256;
const buffer = GPU.device.createBuffer({size:row*texture.height,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
const encoder = GPU.device.createCommandEncoder();
encoder.copyTextureToBuffer({texture},{buffer,bytesPerRow:row},[texture.width,texture.height]);
GPU.device.queue.submit([encoder.finish()]); await buffer.mapAsync(GPUMapMode.READ);
const actual = new Uint8Array(buffer.getMappedRange()), expected = new Uint8Array(data.buffer,data.byteOffset,data.byteLength);
let mismatch = '';
for (let cy=0;cy<CH;cy++) for(let cx=0;cx<CW;cx++) {
const c=cy*CW+cx; if(!GPU.resident[c] || renderDirty[c]) continue;
for(let y=cy*32/scale;y<Math.min(texture.height,(cy+1)*32/scale);y++)
for(let x=cx*32/scale;x<Math.min(texture.width,(cx+1)*32/scale);x++) for(let b=0;b<bytes;b++) {
checked++; if(actual[y*row+x*bytes+b]!==expected[(y*stride+x)*bytes+b] && !mismatch) mismatch=`${texture.format} at ${x},${y}`;
}
}
buffer.unmap(); buffer.destroy(); if(mismatch) throw Error('Stale GPU upload: '+mismatch);
}
return checked;
}
-103
View File
@@ -1,103 +0,0 @@
// Propagated coloured light from glowing and burning cells.
// ---------- Lighting ----------
// Light is computed on a coarse grid (2 or 4 grains per cell), written into a
// small canvas, then the GPU upscales it smoothly and multiplies it over the world.
function builtForLight(i, x, y) {
return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i];
}
function looseForLight(i) {
for (let depth = 0; depth <= 12 && i < N; depth++, i += W) {
const m = mat[i], ph = PH[m];
if (builtForLight(i, i % W, (i / W) | 0)) return true;
if (ph !== PH_POWDER && ph !== PH_LIQUID) return m === EMPTY || ph === PH_GAS;
}
return false;
}
function computeLight() {
const res = SET.lighting === 'fast' ? 4 : 2;
const M = Math.ceil(28 / res);
const lx0 = Math.floor(R.vx0 / res) - M, ly0 = Math.floor(R.vy0 / res) - M;
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
if (lw !== R.lw || lh !== R.lh || res !== R.lres) {
R.lw = lw; R.lh = lh; R.lres = res;
R.lr = new Float32Array(lw * lh); R.lg = new Float32Array(lw * lh); R.lb = new Float32Array(lw * lh); R.latt = new Float32Array(lw * lh);
R.lightC.width = lw; R.lightC.height = lh;
R.lightImg = R.lightX.createImageData(lw, lh);
R.lightPx = new Uint32Array(R.lightImg.data.buffer);
R.lightRoof = new Int32Array(lw);
}
R.lx0 = lx0; R.ly0 = ly0;
const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt;
// per-grain transmittance: air .978, gas .96, liquid .93, solid .935 (light soaks a little way into rock)
const P = res;
const aAir = Math.pow(0.978, P), aGas = Math.pow(0.96, P), aLiq = Math.pow(0.93, P), aSol = Math.pow(0.9, P);
const skyK = SET.daylight * (1 - 0.32 * (R.rain || 0)); // overcast while it rains
// Only sampled view columns, starting at the simulation roof; nothing below the view matters.
const roofEnd = Math.min(H, (ly0 + lh) * res), lightRoof = R.lightRoof;
for (let lx = 0; lx < lw; lx++) {
const x = (lx0 + lx) * res;
if (x < 0 || x >= W) continue;
let y = roofY[x];
for (; y < roofEnd; y++) {
const i = y * W + x, ph = PH[mat[i]];
if ((ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) && !builtForLight(i, x, y) && !((ph === PH_POWDER || ph === PH_LIQUID) && looseForLight(i))) break;
}
lightRoof[lx] = y;
}
for (let ly = 0; ly < lh; ly++) {
const wy = (ly0 + ly) * res;
for (let lx = 0; lx < lw; lx++) {
const wx = (lx0 + lx) * res, k = ly * lw + lx;
if (wx < 0 || wy < 0 || wx >= W || wy >= H) { la[k] = 0; lr[k] = lg[k] = lb[k] = 0; continue; }
const i = wy * W + wx, m = mat[i], ph = PH[m];
la[k] = builtForLight(i, wx, wy) || ((ph === PH_POWDER || ph === PH_LIQUID) && looseForLight(i)) ? aAir : ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol;
let r = 0, g = 0, b = 0;
if (wy < lightRoof[lx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; }
else if (wy < surfY[wx] && ph !== PH_SOLID) { const v = skyK * 0.9; r = v; g = v * 0.98; b = v * 0.95; }
// emission from heat: hottest grain in the block
let T = 0;
const xe = Math.min(W, wx + res), ye = Math.min(H, wy + res);
for (let yy = wy; yy < ye; yy++) { const row = yy * W; for (let xx = wx; xx < xe; xx++) { const tt = temp[row + xx]; if (tt > T) T = tt; } }
if (T > 520) {
const gi = glowIndex(T), e = GLOW_W[gi] * 1.35;
r += GLOW_R[gi] / 255 * e; g += GLOW_G[gi] / 255 * e; b += GLOW_B[gi] / 255 * e;
}
lr[k] = r; lg[k] = g; lb[k] = b;
}
}
// bot lamp
if (bot.lamp) {
const cx = (bot.x + bot.w / 2) / res - lx0, cy = (bot.y + bot.h / 2) / res - ly0, rad = 34 / res;
for (let ly = Math.max(0, Math.floor(cy - rad)); ly < Math.min(lh, cy + rad); ly++)
for (let lx = Math.max(0, Math.floor(cx - rad)); lx < Math.min(lw, cx + rad); lx++) {
const d = Math.hypot(lx - cx, ly - cy) / rad;
if (d >= 1) continue;
const v = (1 - d) * (1 - d) * 1.05, k = ly * lw + lx;
if (lr[k] < v) lr[k] = v; if (lg[k] < v * 0.93) lg[k] = v * 0.93; if (lb[k] < v * 0.8) lb[k] = v * 0.8;
}
}
// propagate: forward sweep (from left/up) then backward sweep (from right/down)
for (let ly = 0; ly < lh; ly++) for (let lx = 0; lx < lw; lx++) {
const k = ly * lw + lx, a = la[k];
let r = lr[k], g = lg[k], b = lb[k];
if (lx > 0) { const j = k - 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
if (ly > 0) { const j = k - lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
lr[k] = r; lg[k] = g; lb[k] = b;
}
for (let ly = lh - 1; ly >= 0; ly--) for (let lx = lw - 1; lx >= 0; lx--) {
const k = ly * lw + lx, a = la[k];
let r = lr[k], g = lg[k], b = lb[k];
if (lx < lw - 1) { const j = k + 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
if (ly < lh - 1) { const j = k + lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
lr[k] = r; lg[k] = g; lb[k] = b;
}
// write the light canvas
const fl = SET.ambient, px = R.lightPx, n = lw * lh;
for (let k = 0; k < n; k++) {
let r = lr[k], g = lg[k], b = lb[k];
if (r < fl) r = fl; if (g < fl) g = fl; if (b < fl) b = fl;
r = r >= 1 ? 255 : (r * 255) | 0; g = g >= 1 ? 255 : (g * 255) | 0; b = b >= 1 ? 255 : (b * 255) | 0;
px[k] = 0xff000000 | (b << 16) | (g << 8) | r;
}
if (!R.smoothOn) R.lightX.putImageData(R.lightImg, 0, 0);
}
+80
View File
@@ -0,0 +1,80 @@
// Loose bars, flying grains and queued blueprint blocks in the grain buffer.
function worldObjects() {
const { bw, bh, px, vx0, vy0, smoothOn: sm, lens } = R;
const useFog = SET.fog, incand = SET.incandescence;
// bars and other loose objects
if (!lens) for (const e of ents) {
if (useFog && fogSeen(e.ix, e.iy) < 64) continue;
const ex = e.ix - vx0, ey = e.iy - vy0;
if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue;
const m = e.m;
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
const sx = ex + dx, sy = ey + dy;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
let r = COLR[m], g = COLG[m], b = COLB[m];
const end = dx === 0 || dx === BAR_W - 1;
if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; }
if (e.T > 450 && incand) { const gi = glowIndex(e.T), w = GLOW_W[gi]; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; }
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
if (R.gpuOn && !gpuOverrideHas(sy * bw + sx)) { if (sm) cellPixel(sx + vx0, sy + vy0); else GPU.overrideCount++; }
px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0);
}
}
// grains in flight from the vacuum nozzle, a screw or a belt (rain and snow are drawn over the top, see drawRain)
if (!lens) for (const f of flyers) {
if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue;
const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8;
if (R.gpuOn && !gpuOverrideHas(sy * bw + sx)) { if (sm) cellPixel(sx + vx0, sy + vy0); else GPU.overrideCount++; }
px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v));
}
// blueprint ghosts: blocks waiting to be built
if (ghostBlocks.size) {
const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2;
const pulse = (R.frame >> 3) & 3;
for (const [b, gv] of ghostBlocks) {
const gbx = b % BW, gby = (b / BW) | 0;
if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue;
const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1];
let tr = 90, tg = 190, tb = 230;
if (blocked) { tr = 230; tg = 70; tb = 60; }
else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
const slope = isSlopeBelt(code), diagonal = code === BP_SCREW && (v >= 4 || straightScrewJoins(gbx, gby, v, ghostBlocks));
const joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : diagonal ? (v >= 4 ? diagonalScrewJoins(gbx, gby, v, ghostBlocks) : straightScrewJoins(gbx, gby, v, ghostBlocks)) : 0;
const caps = code === BP_BELT || code === BP_FILTER ? flatBeltCaps(b, ghostBlocks) : 3;
for (let dy = slope ? -9 : diagonal ? -4 : 0; dy < (slope || diagonal ? 8 : BS); dy++) for (let dx = slope ? -5 : diagonal ? -4 : 0; dx < (slope ? 9 : diagonal ? 8 : BS); dx++) {
let sd = 0;
if (diagonal) {
if (v < 4 && ((v & 1) ? dx < 0 || dx >= BS : dy < 0 || dy >= BS)) continue;
const x = gbx * BS + dx, y = gby * BS + dy;
if (x < 0 || y < 0 || x >= W || y >= H) continue;
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
sd = diagonalScrewShape(x + 0.5, y + 0.5, b, v, joins);
if (sd > 0) continue;
}
if (slope) {
const x = gbx * BS + dx, y = gby * BS + dy;
if (x < 0 || y < 0 || x >= W || y >= H) continue;
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code);
const up = !isFilter(code) && slopeUphill(code, v), rib = up && slopePreviewCleat(x, y, b, code);
if (sd > 0 && !rib && (!up || slopeWallShape(x + 0.5, y + 0.5, b, code) > 0)) continue;
if (rib) sd = -0.1;
}
if (isSlope(code) && !slopeCell(code, dx, dy)) continue;
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const k = sy * bw + sx;
if (R.gpuOn && !gpuOverrideHas(k)) cellPixel(sx + vx0, sy + vy0);
const c = px[k];
const edge = slope || diagonal ? sd > -0.8 : (dx === 0 && (caps & 1)) || dy === 0 || (dx === BS - 1 && (caps & 2)) || dy === BS - 1;
const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28;
const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255;
px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a);
}
}
}
}
+16
View File
@@ -167,6 +167,22 @@ function drawPing() {
} }
ctx.restore(); ctx.restore();
} }
// Edges are built once when the cursor enters a different vein.
function drawHoveredVein() {
if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) return;
const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!vein) return;
const ctx = R.ctx, zp = R.zp;
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, R.canvas.width, R.canvas.height); ctx.clip();
ctx.strokeStyle = 'rgba(235,245,255,0.85)'; ctx.lineWidth = 1;
ctx.beginPath();
for (const e of vein.edges) {
if (e[2] < R.vx0 || e[0] > R.vx0 + R.bw || e[3] < R.vy0 || e[1] > R.vy0 + R.bh) continue;
const a = worldToScreenPx(e[0], e[1]);
ctx.moveTo(a.x, a.y); ctx.lineTo(a.x + (e[2] - e[0]) * zp, a.y + (e[3] - e[1]) * zp);
}
ctx.stroke(); ctx.restore();
}
// While the ping recharges, a thin arc around the bot fills up (only with the drill in hand). // While the ping recharges, a thin arc around the bot fills up (only with the drill in hand).
function drawPingRecharge() { function drawPingRecharge() {
if (toolId() !== 'drill' || GAME.pingTick === undefined || tick < GAME.pingTick) return; if (toolId() !== 'drill' || GAME.pingTick === undefined || tick < GAME.pingTick) return;
+7 -17
View File
@@ -2,10 +2,10 @@
thermal palettes, pipes, trees, sky), canvas setup and the camera. */ thermal palettes, pipes, trees, sky), canvas setup and the camera. */
const R = { const R = {
canvas: null, ctx: null, buf: null, bctx: null, img: null, px: null, canvas: null, ctx: null, px: null,
glowC: null, glowX: null, glowImg: null, glowPx: null, glowW: 0, glowH: 0, glowPx: null,
bw: 0, bh: 0, zp: 3, camX: 0, camY: 0, vx0: 0, vy0: 0, bw: 0, bh: 0, zp: 3, camX: 0, camY: 0, vx0: 0, vy0: 0,
lr: null, lg: null, lb: null, latt: null, lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1, lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
frame: 0, lens: false, particles: [], frame: 0, lens: false, particles: [],
}; };
@@ -138,13 +138,7 @@ function buildSky() {
function initRender(canvas) { function initRender(canvas) {
R.canvas = canvas; R.canvas = canvas;
R.ctx = canvas.getContext('2d', { alpha: false }); R.ctx = canvas.getContext('2d');
R.buf = document.createElement('canvas');
R.bctx = R.buf.getContext('2d');
R.glowC = document.createElement('canvas');
R.glowX = R.glowC.getContext('2d');
R.lightC = document.createElement('canvas');
R.lightX = R.lightC.getContext('2d');
} }
function resizeRender() { function resizeRender() {
@@ -157,15 +151,11 @@ function resizeRender() {
const bw = Math.ceil(cw / zp) + 2, bh = Math.ceil(ch / zp) + 2; const bw = Math.ceil(cw / zp) + 2, bh = Math.ceil(ch / zp) + 2;
if (bw !== R.bw || bh !== R.bh) { if (bw !== R.bw || bh !== R.bh) {
R.bw = bw; R.bh = bh; R.bw = bw; R.bh = bh;
R.buf.width = bw; R.buf.height = bh; R.px = new Uint32Array(bw * bh);
R.img = R.bctx.createImageData(bw, bh);
R.px = new Uint32Array(R.img.data.buffer);
// the smooth look also needs, per grain: the colour behind it, any tree, and occupancy fields // the smooth look also needs, per grain: the colour behind it, any tree, and occupancy fields
R.backPx = new Uint32Array(bw * bh); R.treePx = new Uint32Array(bw * bh); R.fieldPx = new Uint32Array(bw * bh); R.moldMask = new Uint8Array(bw * bh); R.backPx = new Uint32Array(bw * bh); R.treePx = new Uint32Array(bw * bh); R.fieldPx = new Uint32Array(bw * bh); R.moldMask = new Uint8Array(bw * bh);
const gw = Math.ceil(bw / 2), gh = Math.ceil(bh / 2); R.glowW = Math.ceil(bw / 2); R.glowH = Math.ceil(bh / 2);
R.glowC.width = gw; R.glowC.height = gh; R.glowPx = new Uint32Array(R.glowW * R.glowH);
R.glowImg = R.glowX.createImageData(gw, gh);
R.glowPx = new Uint32Array(R.glowImg.data.buffer);
R.lw = 0; R.lw = 0;
} }
} }
-111
View File
@@ -1,111 +0,0 @@
// Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
const SM_SLOPE_HELPERS = `
// The same rounded strokes as slopeBeltShape; y/z carry distance along/across the band.
vec3 slopeStroke(vec2 p, vec2 a, vec2 b, float origin) {
vec2 ray = b - a; float len = length(ray); ray /= len;
float along = dot(p - a, ray), across = dot(p - a, vec2(-ray.y, ray.x));
vec2 q = abs(vec2(along - len * 0.5, across)) - vec2(len * 0.5 - 0.75, 1.1);
float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - 0.75;
return vec3(sd, origin + along, across + 2.0);
}
vec3 slopeCorner(vec2 p, vec2 a, float origin) {
vec2 d = p - a; float radius = length(d);
return vec3(radius - 1.85, origin + d.x, 2.0 + d.y);
}
vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) {
if ((joins & 64) != 0) p.y -= 0.45;
float sy = grade < 0.0 ? 4.0 : 0.0;
vec2 a = vec2(-grade * 1.30814755, sy + 1.30814755), b = vec2(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755);
if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; }
else if ((joins & 4) != 0) a -= vec2(4.0, grade * 4.0);
if ((joins & 2) != 0) { b.y = 6.0 - sy; b.x = (b.y - sy - 2.61629509) / grade; }
else if ((joins & 8) != 0) b += vec2(4.0, grade * 4.0);
vec2 axis = vec2(1.0, grade) * 0.70710678;
vec3 best = slopeStroke(p, a, b, dot(worldOrigin + a, axis));
if ((joins & 1) != 0) {
vec3 h = slopeStroke(p, vec2(-2.0, a.y), a, worldOrigin.x - 2.0);
if (h.x < best.x) best = h;
h = slopeCorner(p, a, worldOrigin.x + a.x);
if (h.x < best.x) best = h;
}
if ((joins & 2) != 0) {
vec3 h = slopeStroke(p, b, vec2(6, b.y), worldOrigin.x + b.x);
if (h.x < best.x) best = h;
h = slopeCorner(p, b, worldOrigin.x + b.x);
if (h.x < best.x) best = h;
}
return best;
}
`;
const SM_SLOPE_DRAW = `
if (nc == 31 || nc == 32) {
vec4 tag = cellAt(tTree, c);
float grade = nc == 31 ? -1.0 : 1.0;
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
float sy = grade < 0.0 ? 4.0 : 0.0;
float foot = (local.x + grade * (local.y - sy)) * 0.5;
float depth = (grade * local.x - local.y + sy) * 0.70710678;
int wallFlags = int(tag.a * 255.0 + 0.5);
float ends = max((wallFlags & 8) == 0 ? -foot : -100.0, (wallFlags & 16) == 0 ? foot - 4.0 : -100.0);
float sd = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)}));
float phase = float(wallFlags & 7);
float wave = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265);
vec3 rubber = mix(vec3(0.20,0.22,0.25), vec3(0.25,0.27,0.31), wave);
C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd));
// the ribs, where the simulation has them: rubber bars standing straight out of an uphill belt,
// one every ${CLEAT_SPACING} face cells, moved along by the belt's phase
float dirS = (wallFlags & 32) != 0 ? 1.0 : -1.0;
if (grade * dirS < 0.0) {
float rc = (w.x - local.x) + foot - (grade < 0.0 ? 1.0 : 0.0) - dirS * phase;
float off = (mod(rc + ${(CLEAT_SPACING / 2).toFixed(1)}, ${CLEAT_SPACING.toFixed(1)}) - ${(CLEAT_SPACING / 2).toFixed(1)}) * 1.41421356;
float ribTop = ${((2 * CLEAT_REACH + 1) * Math.SQRT1_2).toFixed(3)};
float rd = length(vec2(off, depth - clamp(depth, 0.0, ribTop))) - 0.5;
vec3 rib = mix(vec3(0.50,0.52,0.56), vec3(0.70,0.72,0.76), smoothstep(ribTop - 1.2, ribTop, depth));
rib *= mix(0.78, 1.0, smoothstep(-0.5, 0.2, off * -dirS * grade));
C = mix(C, rib, 1.0 - smoothstep(-px, px, rd));
}
}
// Slopes use the flat belt's band, rollers and mesh in coordinates along the run.
if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
(int(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 &&
cellAt(tTree, c).r * 255.0 >= 22.5 && cellAt(tTree, c).r * 255.0 <= 30.5) {
int slopeClass = int(cellAt(tTree, c).r * 255.0 + 0.5);
int shape = (slopeClass - 23) / 2;
bool mesh = shape >= 2;
float grade = (shape == 0 || shape == 2) ? -1.0 : 1.0;
float dir = ((slopeClass - 23) % 2) == 1 ? 1.0 : -1.0;
vec4 tag = cellAt(tTree, c);
int joins = int(tag.a * 255.0 + 0.5);
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
vec3 shapePos = slopeShape(local, w - local, grade, joins);
float sd = shapePos.x, axis = shapePos.y, depth = shapePos.z;
float time = !mesh && (joins & 16) != 0 ? uTime : 0.0;
vec2 lb = vec2(axis, depth), alongWorld = vec2(axis, depth);
float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5));
vec3 belt;
if (-sd < (mesh ? 1.1 : 0.8)) {
belt = mesh ? mix(vec3(0.22,0.25,0.29), vec3(0.48,0.53,0.59), depth < 2.0 ? 0.85 : 0.35) : bandColor(lb, rg);
belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
} else if (mesh) {
vec2 cg = abs(fract(alongWorld) - 0.5);
float bw2 = max(px * 0.9, 0.09);
float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
vec3 steel = dot(own.rgb, vec3(1)) > 0.01 ? mix(vec3(0.42, 0.44, 0.5), own.rgb, 0.4) : vec3(0.42, 0.44, 0.5);
belt = mix(steel * 0.78, C * 0.8, hole);
} else {
vec2 dv = vec2(mod(axis, 2.0) - 1.0, depth - 2.0);
float rd = length(dv);
belt = vec3(0.15, 0.16, 0.18);
if (rd < 0.95) {
float ang = atan(dv.y, dv.x) - dir * time * 30.0 / 0.95;
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
belt = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
belt *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
belt *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
}
}
C = mix(C, belt, 1.0 - smoothstep(-px, px, sd));
}
`;
-377
View File
@@ -1,377 +0,0 @@
// Smooth shaders: vertex and fragment programs for the rounded world look.
const SM_VS = `#version 300 es
void main() {
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const SM_FS = `#version 300 es
precision highp float;
precision highp int;
uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow;
uniform vec2 uSize, uOrigin, uLightSize, uGlowSize;
uniform vec4 uView, uLightMap;
uniform float uGlowK, uTime, uSurf, uRain;
out vec4 o;
float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vn(vec2 p) {
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); }
// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes
vec4 bsp(sampler2D t, vec2 q) {
vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f;
vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0;
vec2 g0 = w0 + w1, g1 = w2 + w3;
vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize;
return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0))
+ g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0));
}
vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); }
int cls(vec4 c) { return int(c.a * 255.0 + 0.5); }
float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; }
float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains
float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; }
float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; }
float isN(int n, int k) { return n == k ? 1.0 : 0.0; }
// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where
// the line of belts ends, the end is square with rounded corners. Negative inside.
float beltSD(vec2 lb, bool endL, bool endR) {
float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75;
vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85);
vec2 q = abs(lb - c) - hs + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so
// the line only rounds off where the whole run ends.
float beltLineSD(ivec2 c, vec2 lb) {
int caps = int(cellAt(tTree, c).a * 255.0 + 0.5);
return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0);
}
// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and
// back along the bottom.
float bandRidge(vec2 w, vec2 lb, float dir) {
float run = lb.y < 2.0 ? 1.0 : -1.0;
return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5));
}
vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); }
${SM_SLOPE_HELPERS}
void main() {
float zp = uView.z, px = 1.0 / zp;
vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y);
vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded
vec2 w = q + uOrigin; // position in the world
vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner
vec2 scr = q - uView.xy; // grains from the screen's top-left corner
float shc = uView.w * px; // screen height in grains
ivec2 c = ivec2(floor(q));
vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq);
vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1));
int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3);
vec4 own = cellAt(tCol, c); int nc = cls(own);
vec2 s = smoothstep(0.1, 0.9, t);
vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
// fields, and everything that needs screen derivatives (worked out before any branching)
vec4 Fb = bsp(tField, q);
vec4 F = mix(lin(tField, q), Fb, 0.62);
// loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter"
vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
float M = clamp(F.r + pw, 0.0, 1.0);
float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4);
vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g));
// ---- behind everything: sky with clouds and far hills, or the cave's back wall
vec4 back = lin(tBack, q);
vec3 C = back.rgb;
float sky = back.a;
if (sky > 0.01) {
vec3 S = C;
vec2 P0 = scr + cam * 0.08;
// clouds; while it rains they thicken and turn grey
float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05)));
float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0);
S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi);
float k1 = 0.16; vec2 P1 = scr + cam * k1;
float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1;
float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7)));
float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
S = mix(S, far, m1);
float k2 = 0.38; vec2 P2 = scr + cam * k2;
float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2;
float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3)));
float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
S = mix(S, near, m2);
C = mix(C, S, sky);
}
// the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion)
float notSky = 1.0 - sky;
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
float near = textureLod(tField, q / uSize, 2.2).r;
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
// ---- trees (behind the ground and everything on it)
if (F.a > 0.2) {
vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1));
vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
float sm = m.x + m.y + m.z + m.w;
if (sm > 1e-4) {
vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
float lv = smoothstep(0.6, 0.9, leaf);
tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
float d = (F.a - 0.36) / fwA;
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
}
}
// ---- smoke, steam and flame: soft, see-through puffs
if (Fb.b > 0.02) {
vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
float sm = m.x + m.y + m.z + m.w;
vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb;
if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92);
}
// ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line
float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
if (anyL > 0.0 && lf > 0.3) {
vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
float sm = m.x + m.y + m.z + m.w;
vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25));
float gl = length(gL);
float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0;
float band = 1.0 - smoothstep(0.5, 0.75, F.g);
lc += vec3(0.16, 0.18, 0.2) * topSide * band;
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
}
// ---- the ground: rounded, outlined, with a lit top edge and a shaded underside
if (F.r > 0.3) {
vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
float sm = m.x + m.y + m.z + m.w;
vec3 blur = bsp(tColL, q).rgb, tc = own.rgb;
float bd = 1e9;
if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
if (sm < 1e-4) tc = own.rgb;
tc *= 0.84 + 0.3 * fbm(w * 0.28);
tc *= 0.95 + 0.1 * vn(w * 1.6);
float gl = length(gT);
vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0);
float band = 1.0 - smoothstep(0.5, 0.9, M);
float lit = dot(nrm, normalize(vec2(-0.5, -1.0)));
tc *= 1.0 + 0.42 * lit * band;
// Keep the air outline; a softer, screen-width seam also separates rock from loose grains.
float dm = (M - 0.5) / fwM;
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
float dt = (F.r - 0.5) / fwT;
if (nc == 1 && pw > 0.0) tc *= mix(0.65, 1.0, smoothstep(0.0, 1.5, dt));
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
}
${SM_SLOPE_DRAW}
// ---- loose grains: little round pebbles; a pocket closed in by four grains is filled
int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
if (np > 0) {
float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6;
for (int k = 0; k < 4; k++) {
ivec2 o4 = ivec2(k & 1, k >> 1);
int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3;
if (nk != 2) continue;
vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3;
vec2 ce = vec2(c0 + o4) + 0.5;
float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5);
vec2 dv = (q - ce) / r;
float dd = length(dv);
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
}
float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best);
if (closed && F.r < 0.5) cover = 1.0;
// Pebbles stop at the rock face so they cannot paint over its thin seam.
cover *= 1.0 - smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT);
if (cover > 0.0) {
float r2 = min(1.0, dot(gd, gd));
vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25));
float sh = 0.98 + 0.18 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6))));
sh *= mix(1.0, 0.94, smoothstep(0.7 - px * 0.2, 1.15 + px * 0.2, best));
vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh;
if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best));
C = mix(C, pc, cover);
}
}
// ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines
if (nc == 4) C = own.rgb;
else if (nc == 7 || nc == 8) {
// belt: a rubber band round two copper rollers per block; the top run moves the load
float dir = nc == 8 ? 1.0 : -1.0;
vec2 lb = fract(w * 0.25) * 4.0;
// outline: square ends with rounded corners where the line of belts (and filters) ends
float sd = beltLineSD(c, lb);
float inside = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
float edge = -sd;
vec3 bc;
if (edge < 0.8) {
bc = bandColor(lb, bandRidge(w, lb, dir));
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc;
float rd = length(dv);
bc = vec3(0.15, 0.16, 0.18);
if (rd < 0.95) {
float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95;
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
}
}
C = mix(C, bc, inside);
}
}
else if ((nc >= 9 && nc <= 12) || (nc >= 19 && nc <= 22)) {
// screw conveyor: a copper pipe; what is inside shows through, and the screw's flights turn
bool vert = nc == 9 || nc == 11;
float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0;
float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x;
float screwSD = -1.0, capRing = 0.0;
vec4 screwTag = cellAt(tTree, c);
int screwJoins = int(screwTag.a * 255.0 + 0.5);
// Straight bend tags use six bits; background trees use 128/255.
bool shaped = nc >= 19 || (screwJoins > 0 && screwJoins < 64);
if (shaped) {
vec2 d = vec2(1.0, nc == 19 || nc == 21 ? -1.0 : 1.0);
vec4 tag = screwTag;
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w) - 2.0;
int joins = screwJoins;
screwSD = 100.0;
for (int end = 0; end < 2; end++) {
int join = (joins >> (end * 3)) & 7;
float side = end == 0 ? -1.0 : 1.0;
bool diagonal = nc >= 19 ? join == 0 || join >= 5 : join != 0;
float grade = nc >= 19 ? d.y : join == 1 ? -1.0 : 1.0;
vec2 ray = diagonal ? (vert ? vec2(side * grade, side) : vec2(side, side * grade)) * 0.70710678 :
nc < 19 ? (vert ? vec2(0, side) : vec2(side, 0)) :
join == 1 ? vec2(0, -1) : join == 2 ? vec2(1, 0) : join == 3 ? vec2(0, 1) : vec2(-1, 0);
float t = dot(local, ray), cross2 = dot(local, vec2(-ray.y, ray.x));
float radius = length(vec2(cross2, min(0.0, t))), cap = t - (diagonal ? 2.82842712 : 2.0);
// A connected mouth meets another opaque pipe; fading its edge leaves a seam.
float sd = max(radius - 2.0, (joins & (64 << end)) != 0 ? cap : -10.0);
if (sd < screwSD) {
screwSD = sd;
a = 2.0 + (side * cross2 < 0.0 ? -radius : radius);
along = (nc >= 19 ? dot(w - local, d) * 0.70710678 : vert ? w.y - local.y : w.x - local.x) + side * t;
capRing = (joins & (64 << end)) != 0 ? smoothstep(-0.55, -0.15, cap) : 0.0;
}
}
if (nc >= 19) sp2 = nc == 19 || nc == 20 ? 1.0 : -1.0;
}
float wall = 0.62;
vec3 tc;
if (a < wall || a > 4.0 - wall || capRing > 0.5) {
float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore
float cyl = sin(clamp(u2, 0.0, 1.0) * 1.5708);
tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
if (a < wall) tc *= 1.08;
float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
if (nc < 19) tc *= 1.0 - 0.3 * ring;
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
} else {
bool full = own.r + own.g + own.b > 0.0;
float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5);
float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore;
tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8));
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
}
if (shaped) {
bool full = own.r + own.g + own.b > 0.0;
if (full) C = mix(C, own.rgb, 1.0 - smoothstep(0.36, 0.5, length(fract(w) - 0.5)));
C = mix(C, tc, 1.0 - smoothstep(2.0 - px, 2.0 + px, screwSD + 2.0));
} else C = tc;
}
else if (nc == 6 || (nc >= 15 && nc <= 18)) {
vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb;
float e = min(min(lb.x, lb.y), min(rb.x, rb.y));
vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
if (nc >= 15) {
vec2 sl = lb;
if (nc == 16 || nc == 18) sl.x = 4.0 - sl.x;
if (nc >= 17) sl.y = 4.0 - sl.y;
float d = (sl.y - sl.x) * 0.70710678;
tc *= 1.0 + 0.2 * (1.0 - smoothstep(0.0, 0.6, d));
C = mix(C, tc, smoothstep(-px * 0.5, px * 0.5, d));
} else C = tc;
}
else if (nc == 13 || nc == 14) {
// filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a
// row of belts and filters reads as one belt, rounded only where the whole line ends. The
// band is perforated; its colour tag sits on the top run for what rides along, on the
// bottom run for what drops through.
float dir = nc == 14 ? 1.0 : -1.0;
vec2 lb = fract(w * 0.25) * 4.0;
vec4 tag = cellAt(tTree, c);
bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0;
float sd = beltLineSD(c, lb);
float inside = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
float edge = -sd;
vec3 steel = vec3(0.42, 0.44, 0.5);
// through the holes: whatever is behind (water, sky, rock), or what is passing through
vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8;
vec2 cg = abs(fract(w) - 0.5);
float bw2 = max(px * 0.9, 0.09);
float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
vec3 fc;
if (edge < 0.8) {
float rg = bandRidge(w, lb, dir);
vec3 band = bandColor(lb, rg);
if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg);
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
fc = mix(steel * 0.78, behind, hole);
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
}
C = mix(C, fc, inside);
}
}
// ---- light, then the glow of hot things on top
if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb;
if (uGlowK > 0.0) {
vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize;
vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25;
g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125;
g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625;
C += g * uGlowK;
}
o = vec4(C, 1.0);
}`;
-99
View File
@@ -1,99 +0,0 @@
// Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
const SM = { tried: false, ok: false, why: '', gl: null, canvas: null, prog: null, tex: {}, u: {}, t0: performance.now(), white: null, surf: -1, scale: 1, ms: 0, n: 0 };
function smCompile(gl, type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
return sh;
}
// Is the smooth look possible here (WebGL2)? Set up once, on first use.
function smoothAvailable() {
if (SM.tried) return SM.ok;
SM.tried = true;
try {
const cv = document.createElement('canvas');
const gl = cv.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: true, premultipliedAlpha: false });
if (!gl) { SM.why = 'nogl'; throw new Error('WebGL2 is not available'); }
// a browser drawing without the graphics card would crawl: keep the pixel look there
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const rend = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : '';
if (/swiftshader|llvmpipe|softpipe|software/i.test(rend) && !window.__allowSoftwareGL) { SM.why = 'software'; throw new Error('software rendering: ' + rend); }
const prog = gl.createProgram();
gl.attachShader(prog, smCompile(gl, gl.VERTEX_SHADER, SM_VS));
gl.attachShader(prog, smCompile(gl, gl.FRAGMENT_SHADER, SM_FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
for (const n of ['uSize', 'uOrigin', 'uLightSize', 'uGlowSize', 'uView', 'uLightMap', 'uGlowK', 'uTime', 'uSurf', 'uRain']) SM.u[n] = gl.getUniformLocation(prog, n);
// texture unit for each sampler; linear ones are sampled between grains
const texs = [['tCol', false, false], ['tColL', true, false], ['tBack', true, false], ['tTree', false, false], ['tField', true, true], ['tLight', true, false], ['tGlow', true, false]];
texs.forEach(([name, linear, mip], unit) => {
const t = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mip ? gl.LINEAR_MIPMAP_LINEAR : linear ? gl.LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.uniform1i(gl.getUniformLocation(prog, name), unit);
SM.tex[name] = { t, unit, mip, w: 0, h: 0 };
});
gl.bindVertexArray(gl.createVertexArray());
SM.canvas = cv; SM.gl = gl; SM.prog = prog; SM.ok = true;
SM.white = new Uint8Array([255, 255, 255, 255]);
cv.addEventListener('webglcontextlost', e => { e.preventDefault(); SM.ok = false; });
} catch (e) {
console.warn('Smooth look unavailable, using pixels:', e && e.message);
SM.ok = false;
}
return SM.ok;
}
function smUpload(name, w, h, data) {
const gl = SM.gl, T = SM.tex[name];
gl.activeTexture(gl.TEXTURE0 + T.unit); gl.bindTexture(gl.TEXTURE_2D, T.t);
if (T.w !== w || T.h !== h) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); T.w = w; T.h = h; }
else gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, data);
if (T.mip) gl.generateMipmap(gl.TEXTURE_2D);
}
const smBytes = a => new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
function smoothDraw(lighting, glowOn) {
const t0 = performance.now();
// the GPU pass can run below full resolution on a slow graphics card; it is scaled up smoothly
const gl = SM.gl, cv = SM.canvas, sc = SM.scale, cw = Math.max(1, Math.round(R.canvas.width * sc)), ch = Math.max(1, Math.round(R.canvas.height * sc)), u = SM.u;
if (cv.width !== cw || cv.height !== ch) { cv.width = cw; cv.height = ch; }
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; }
gl.viewport(0, 0, cw, ch);
gl.useProgram(SM.prog);
const bw = R.bw, bh = R.bh;
smUpload('tCol', bw, bh, smBytes(R.px));
smUpload('tColL', bw, bh, smBytes(R.px));
smUpload('tBack', bw, bh, smBytes(R.backPx));
smUpload('tTree', bw, bh, smBytes(R.treePx));
smUpload('tField', bw, bh, smBytes(R.fieldPx));
if (lighting && R.lightImg) smUpload('tLight', R.lw, R.lh, smBytes(R.lightImg.data)); else smUpload('tLight', 1, 1, SM.white);
if (glowOn) smUpload('tGlow', R.glowC.width, R.glowC.height, smBytes(R.glowImg.data)); else smUpload('tGlow', 1, 1, SM.white);
gl.uniform2f(u.uSize, bw, bh);
gl.uniform2f(u.uOrigin, R.vx0, R.vy0);
gl.uniform4f(u.uView, R.camX - R.vx0, R.camY - R.vy0, R.zp * (ch / R.canvas.height), ch);
gl.uniform4f(u.uLightMap, lighting ? R.lx0 * R.lres - R.vx0 : 0, lighting ? R.ly0 * R.lres - R.vy0 : 0, R.lres || 1, lighting ? 1 : 0);
gl.uniform2f(u.uLightSize, R.lw || 1, R.lh || 1);
gl.uniform2f(u.uGlowSize, R.glowC.width || 1, R.glowC.height || 1);
gl.uniform1f(u.uGlowK, glowOn ? (SET.glow === 'high' ? 0.9 : 0.55) : 0);
gl.uniform1f(u.uTime, (performance.now() - SM.t0) / 1000);
gl.uniform1f(u.uSurf, SM.surf);
gl.uniform1f(u.uRain, R.rain || 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const ctx = R.ctx;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(cv, 0, 0, cw, ch, 0, 0, R.canvas.width, R.canvas.height);
smoothPace(performance.now() - t0);
}
// Keep the frame rate up: drop the GPU pass's resolution when it is slow, raise it again when there
// is room, and if even half resolution is too slow, give up on the smooth look for this session.
function smoothPace(ms) {
SM.ms = SM.n ? SM.ms * 0.8 + ms * 0.2 : ms;
if (++SM.n < 15) return;
SM.n = 1;
if (SM.ms > 40 && SM.scale <= 0.5) { SM.ok = false; SM.why = 'slow'; return; }
if (SM.ms > 18 && SM.scale > 0.5) SM.scale = Math.max(0.5, SM.scale - (SM.ms > 60 ? 0.5 : 0.25));
else if (SM.ms < 6 && SM.scale < 1) SM.scale = Math.min(1, SM.scale + 0.25);
}
+19 -377
View File
@@ -3,16 +3,19 @@ function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 1
// drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look. // drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
// ---------- World pixels ---------- // ---------- World pixels ----------
function drawWorld() { function drawWorld() {
const bw = R.bw, bh = R.bh, px = R.px; const perfStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
if (PERF_MARKS || SET.showPerf) { perfMarks.override = 0; delete perfMarks.gpu; }
if (!GPU.ok) return;
R.gpuOn = true; GPU.canvas.hidden = false;
R.ctx.clearRect(0, 0, R.canvas.width, R.canvas.height);
const bw = R.bw, bh = R.bh;
R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY); R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY);
const vx0 = R.vx0, vy0 = R.vy0; const vx0 = R.vx0, vy0 = R.vy0;
// how hard it is raining around the camera, eased so the sky greys over gently // how hard it is raining around the camera, eased so the sky greys over gently
R.rain = (R.rain || 0) * 0.98 + rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + bw / 2)))) * 0.02; R.rain = (R.rain || 0) * 0.98 + rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + bw / 2)))) * 0.02;
const rk = R.rain; // Smooth inputs carry a class in alpha; only exceptional cells use the CPU in WebGPU mode.
// Smooth look: the colours below are still worked out per grain, but each grain also gets a const sm = R.smoothOn = SET.look === 'smooth' && !R.lens;
// class (alpha byte) and the GPU draws rounded shapes from them (see smooth-shaders.js) const mmask = R.moldMask;
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && typeof smoothAvailable === 'function' && smoothAvailable();
const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask;
if (sm) { if (sm) {
mmask.fill(0); mmask.fill(0);
for (const mo of molds) { for (const mo of molds) {
@@ -28,377 +31,16 @@ function drawWorld() {
} }
} }
prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh); prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh);
const useFog = SET.fog;
const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black
const lighting = SET.lighting !== 'off' && !R.lens; const lighting = SET.lighting !== 'off' && !R.lens;
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight(); const lightUpdate = lighting ? gpuLightGrid() : false;
const grain = SET.grain, incand = SET.incandescence; const colorStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
const anim = R.frame; const glowOn = SET.glow !== 'off' && !R.lens;
const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno; if (glowOn) R.glowPx.fill(0);
const glowOn = SET.glow !== 'off' && !lens; gpuOverrides();
const gpx = R.glowPx, gw = R.glowC.width; worldObjects();
if (glowOn) gpx.fill(0); if (PERF_MARKS || SET.showPerf) {
for (let by = 0; by < bh; by++) { perfMarks.color = performance.now() - colorStart;
const y = vy0 + by; perfMarks.prepare = colorStart - perfStart;
const row = by * bw;
if (y < 0 || y >= H) { for (let bx = 0; bx < bw; bx++) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } } continue; }
const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW;
const yw = y * W;
let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y];
if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; }
for (let bx = 0; bx < bw; bx++) {
const x = vx0 + bx;
if (x < 0 || x >= W) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; }
let fv = 255;
if (useFog) {
const fx = x / FOG, j = Math.floor(fx), t = fx - j, j1 = Math.min(FW - 1, j + 1);
const a = fog[fr + j], b = fog[fr + j1], c = fog[fn + j], d = fog[fn + j1];
if ((a & b & c & d) !== 255) fv = (a + (b - a) * t) * (1 - ft) + (c + (d - c) * t) * ft;
if (!fv) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; }
// ease the fade into black (smoothstep) and break up the half-resolution steps with grain
// noise that is strongest mid-fade and nothing at either end, so the outer edge has no rim
if (fv < 255) {
let s = fv / 255;
s = Math.min(1, Math.max(0, s + (noiseB[yw + x] - 128) * (0.6 / 128) * s * (1 - s)));
fv = Math.max(1, 255 * s * s * (3 - 2 * s));
}
}
const i = yw + x, m = mat[i], nz = noiseB[i] - 128;
const T = temp[i];
// Also used by the thermal lens, which always draws in pixels.
let looseShade = 1;
if (!sm && m !== EMPTY && !builtForLight(i, x, y)) {
if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065;
else if (PH[m] === PH_SOLID && (
(x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) ||
(x + 1 < W && PH[mat[i + 1]] === PH_POWDER && !part[i + 1]) ||
(y > 0 && PH[mat[i - W]] === PH_POWDER && !part[i - W]) ||
(y + 1 < H && PH[mat[i + W]] === PH_POWDER && !part[i + W])
)) looseShade = 0.72;
}
let r, g, b;
if (lens) {
const ti = thermIdx(T);
if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; }
else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; }
const p = part[i];
r *= looseShade; g *= looseShade; b *= looseShade;
if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; }
if (fv < 255) { r *= fv / 255; g *= fv / 255; b *= fv / 255; }
px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
continue;
}
// background
let br, bgc, bb, sky = false;
const tb = bgm[i];
if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } }
else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; }
else {
const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt;
const v = sm ? 0 : nz * 0.06;
br = L[0] + v; bgc = L[1] + v; bb = L[2] + v;
}
if (sm) {
// the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one
bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255);
if (tb) {
const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1;
const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f));
tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr;
} else tpx[row + bx] = 0;
}
if (m === EMPTY) { r = br; g = bgc; b = bb; }
else {
const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128;
r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v;
r *= looseShade; g *= looseShade; b *= looseShade;
if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; }
else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; }
else if (m === BELTL || m === BELTR) {
const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3;
if (ly === 0 || ly === 3) {
// top run moves with the load, bottom run returns the other way
const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1;
const k = ly === 0 ? 1 : 0.72;
r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k;
} else if (lx === 1 || lx === 2) {
const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1;
r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers
} else { r = 46; g = 48; b = 54; }
} else if (m === PIPE) {
pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2];
} else if (m === SHAFT) {
const lx = x & 3, ly = y & 3;
if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } }
else { r -= 34; g -= 30; b -= 24; }
}
else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } }
else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } }
else if (m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) {
const lx = x & 3, ly = y & 3, frame = lx === 0 || lx === 3 || ly === 0 || ly === 3;
r = frame ? 96 : 38; g = frame ? 102 : 42; b = frame ? 112 : 50;
}
else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks
else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres
else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } }
else if (m === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } }
const al = ALPHA[m];
if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; }
const dp = digp[i];
if (dp) {
// shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it)
const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k;
r += lift; g += lift; b += lift * 1.08;
}
// built wall blocks read as tiles: light top-left edge, dark bottom-right edge
if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) {
const lx = x & 3, ly = y & 3;
const wc = blockP[(y >> 2) * BW + (x >> 2)];
const edge = isSlope(wc) && (wc === BP_SLOPE_BL || wc === BP_SLOPE_TR ? lx === ly : lx + ly === 3);
if (edge || lx === 0 || ly === 0) { r += 16; g += 16; b += 16; }
else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; }
}
}
// field parts drawn over contents
const sb = slopeBeltAt(i, x, y, sm);
let p = part[i];
if (p === P_FILTER && (sb >= 0 || isSlopeBelt(blockP[slopeSealOwner(i)]))) p = 0;
const db = diagonalScrewBlock(x, y, sm);
let diagonal = false;
if (db >= 0) {
const v = blockV[db], joins = diagonalJoins[db];
const sd = diagonalScrewShape(x + 0.5, y + 0.5, db, v, joins);
p = sm || sd <= 0 ? SCREW_PARTS[v] : 0;
diagonal = !!p;
if (sm) tpx[row + bx] = (joins << 24) | ((y - ((db / BW) | 0) * BS + 8) << 16) | ((x - (db % BW) * BS + 8) << 8);
}
if (p && sm && (IS_SCREW[p] || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } }
else if (p) {
if (IS_SCREW[p]) {
const on = netSat(i) > 0;
const vert = p === P_SCREW_U || p === P_SCREW_D;
const axis = diagonal ? Math.floor(diagonalAlong) : vert ? y : x;
const across = diagonal ? diagonalAcross + 2 : vert ? x & 3 : y & 3;
const dir = diagonal && blockV[db] >= 4 ? (blockV[db] >= 6 ? 1 : -1) : (p === P_SCREW_U || p === P_SCREW_L) ? 1 : -1;
if (diagonal ? Math.abs(diagonalAcross) > 1.38 || diagonalCap > -0.55 : across === 0 || across === 3) { // copper casing on the two long sides; its load shows through
if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; }
if (!diagonal && (axis & 3) === 0) { r -= 24; g -= 18; b -= 12; }
} else {
// screw flights: a diagonal stripe that walks along the tube when powered
const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0);
if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; }
else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); }
}
} else if (p === P_FILTER) {
const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1;
const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv;
if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides
const st = ((x + dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) {
if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; }
else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; }
}
} else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt
const st = ((x - dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; }
} else if ((lx === 0 && !beltLike(fb, 0)) || (lx === 3 && !beltLike(fb, 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends
else if (m === EMPTY) { // mesh holes, tinted with what falls through them
if ((x + y) & 1) { r = 40; g = 42; b = 48; }
else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops
else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; }
else { r = 24; g = 25; b = 28; }
}
} else if (p === P_GRATE) {
if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; }
} else if (p === P_GOPEN) {
if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; }
} else if (p === P_INTAKE) {
if (m === EMPTY) { r = 30; g = 32; b = 36; }
if ((x & 1) === 0) { r += 50; g += 54; b += 60; }
} else if (p === P_PIPE) {
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
}
}
const slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR);
if (slopeArt && !sm) {
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
}
const slopeDetail = slopeDetailAt(i, x, y, sm);
if (slopeDetail) {
const wave = slopeDetail === 2 && ((x + beltGrade(blockP[slopeWallOwner]) * y - slopePhase[slopeWallOwner]) & 3) < 2;
// ribs: light rubber with a pale cap; the far wall behind them: muted, corrugated rubber
const cap = slopeDetail === 1 && slopeWallOwner >= 0 && !cleatMask[i - W + beltGrade(blockP[slopeWallOwner])];
r = slopeDetail === 1 ? (cap ? 178 : 146) : wave ? 64 : 56;
g = slopeDetail === 1 ? (cap ? 182 : 150) : wave ? 68 : 60;
b = slopeDetail === 1 ? (cap ? 190 : 158) : wave ? 76 : 68;
}
// incandescence
if (T > 450 && m !== EMPTY && incand) {
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w;
if (glowOn && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) {
const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4;
gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s);
}
}
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
let top = 0xff000000;
if (sm) {
// what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid,
// 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile)
let cl = 0;
const pt = (p === P_PIPE && !pipeBodyCell(x, y)) || (p === P_FILTER && sb >= 0) ? 0 : p; // the open parts of a pipe block show what is in them
if (IS_SCREW[pt]) cl = SCREW_CLASS[pt];
else if (p === P_FILTER && sb < 0) {
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
cl = blockV[fb] & 128 ? 14 : 13;
tpx[row + bx] = ((0x20 | (fc && fc.inv ? 0x10 : 0) | beltCaps[fb]) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
}
else if (m === BELTL || m === BELTR) {
cl = m === BELTL ? 7 : 8;
tpx[row + bx] = beltCaps[(y >> 2) * BW + (x >> 2)] << 24;
}
else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4;
else if (m !== EMPTY) {
const ph = PH[m];
if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) ? 6 : 1;
else if (ph === PH_POWDER) cl = 2;
else if (ph === PH_LIQUID) cl = 3;
else cl = 5;
}
const wallCode = blockP[(y >> 2) * BW + (x >> 2)];
if (isSlope(wallCode) && ((m === EMPTY && !slopeCell(wallCode, x & 3, y & 3)) || (PH[m] === PH_SOLID && !(flags[i] & F_NAT)))) {
cl = wallCode;
const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]];
r = COLR[wm]; g = COLG[wm]; b = COLB[wm];
}
if (sb >= 0 && (slopeArt || PH[m] === PH_POWDER)) {
const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb];
const slopeClass = 23 + (code - BP_BELT_UP) * 2 + dir;
if (slopeArt) cl = slopeClass;
const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m.length ? f.m[(Math.floor((x + beltGrade(code) * y) / 2) & 65535) % f.m.length] : 0;
tpx[row + bx] = ((slopeBeltJoins[sb] | 128) << 24) | slopeClass |
((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8);
if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; }
}
if (slopeDetail && slopeWallOwner >= 0) {
// ribs and the far wall are both drawn by the wall shader, ribs from the same phase as the simulation
const wb = slopeWallOwner;
cl = beltGrade(blockP[wb]) < 0 ? 31 : 32;
tpx[row + bx] = ((128 | slopePhase[wb] | ((slopeBeltJoins[wb] & 12) << 1) | (blockV[wb] & 1 ? 32 : 0)) << 24) | cl |
((y - ((wb / BW) | 0) * BS + 8) << 16) | ((x - wb % BW * BS + 8) << 8);
}
top = cl << 24;
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !blockP[(y >> 2) * BW + (x >> 2)] && !mach[i] ? 0xff : 0); // terrain: any solid nothing owns, natural or not
}
if (fv < 255) {
const k = fv / 255; r *= k; g *= k; b *= k;
if (sm) {
const c = bpx[row + bx], t = tpx[row + bx];
bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k);
}
}
px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
}
}
// bars and other loose objects
if (!lens) for (const e of ents) {
if (useFog && fogSeen(e.ix, e.iy) < 64) continue;
const ex = e.ix - vx0, ey = e.iy - vy0;
if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue;
const m = e.m;
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
const sx = ex + dx, sy = ey + dy;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
let r = COLR[m], g = COLG[m], b = COLB[m];
const end = dx === 0 || dx === BAR_W - 1;
if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; }
if (e.T > 450 && incand) { const gi = glowIndex(e.T), w = GLOW_W[gi]; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; }
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0);
}
}
// grains in flight from the vacuum nozzle, a screw or a belt (rain and snow are drawn over the top, see drawRain)
if (!lens) for (const f of flyers) {
if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue;
const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8;
px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v));
}
// blueprint ghosts: blocks waiting to be built
if (ghostBlocks.size) {
const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2;
const pulse = (R.frame >> 3) & 3;
for (const [b, gv] of ghostBlocks) {
const gbx = b % BW, gby = (b / BW) | 0;
if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue;
const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1];
let tr = 90, tg = 190, tb = 230;
if (blocked) { tr = 230; tg = 70; tb = 60; }
else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
const slope = isSlopeBelt(code), diagonal = code === BP_SCREW && (v >= 4 || straightScrewJoins(gbx, gby, v, ghostBlocks));
const joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : diagonal ? (v >= 4 ? diagonalScrewJoins(gbx, gby, v, ghostBlocks) : straightScrewJoins(gbx, gby, v, ghostBlocks)) : 0;
const caps = code === BP_BELT || code === BP_FILTER ? flatBeltCaps(b, ghostBlocks) : 3;
for (let dy = slope ? -9 : diagonal ? -4 : 0; dy < (slope || diagonal ? 8 : BS); dy++) for (let dx = slope ? -5 : diagonal ? -4 : 0; dx < (slope ? 9 : diagonal ? 8 : BS); dx++) {
let sd = 0;
if (diagonal) {
if (v < 4 && ((v & 1) ? dx < 0 || dx >= BS : dy < 0 || dy >= BS)) continue;
const x = gbx * BS + dx, y = gby * BS + dy;
if (x < 0 || y < 0 || x >= W || y >= H) continue;
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
sd = diagonalScrewShape(x + 0.5, y + 0.5, b, v, joins);
if (sd > 0) continue;
}
if (slope) {
const x = gbx * BS + dx, y = gby * BS + dy;
if (x < 0 || y < 0 || x >= W || y >= H) continue;
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code);
const up = !isFilter(code) && slopeUphill(code, v), rib = up && slopePreviewCleat(x, y, b, code);
if (sd > 0 && !rib && (!up || slopeWallShape(x + 0.5, y + 0.5, b, code) > 0)) continue;
if (rib) sd = -0.1;
}
if (isSlope(code) && !slopeCell(code, dx, dy)) continue;
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const k = sy * bw + sx, c = px[k];
const edge = slope || diagonal ? sd > -0.8 : (dx === 0 && (caps & 1)) || dy === 0 || (dx === BS - 1 && (caps & 2)) || dy === BS - 1;
const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28;
const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255;
px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a);
}
}
}
if (sm) { smoothDraw(lighting, glowOn); return; }
R.bctx.putImageData(R.img, 0, 0);
const ctx = R.ctx, zp = R.zp;
ctx.imageSmoothingEnabled = false;
const ox = -(R.camX - vx0) * zp, oy = -(R.camY - vy0) * zp;
ctx.drawImage(R.buf, 0, 0, bw, bh, Math.round(ox), Math.round(oy), bw * zp, bh * zp);
if (lighting) {
const res = R.lres;
ctx.save();
ctx.globalCompositeOperation = 'multiply';
ctx.imageSmoothingEnabled = true;
const lx = (R.lx0 * res - R.camX) * zp, ly = (R.ly0 * res - R.camY) * zp;
ctx.drawImage(R.lightC, 0, 0, R.lw, R.lh, lx, ly, R.lw * res * zp, R.lh * res * zp);
ctx.restore();
}
if (glowOn) {
R.glowX.putImageData(R.glowImg, 0, 0);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = SET.glow === 'high' ? 0.9 : 0.5;
ctx.imageSmoothingEnabled = true;
const blur = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2));
try { ctx.filter = `blur(${blur.toFixed(1)}px)`; } catch (e) { /* no filter support */ }
ctx.drawImage(R.glowC, 0, 0, R.glowC.width, R.glowC.height, Math.round(ox), Math.round(oy), R.glowC.width * 2 * zp, R.glowC.height * 2 * zp);
ctx.filter = 'none';
ctx.restore();
} }
gpuDraw(lighting, glowOn, lightUpdate);
} }
+2
View File
@@ -4,6 +4,7 @@
/*__CSS__*/ /*__CSS__*/
</style> </style>
<canvas id="gpu" aria-hidden="true" hidden></canvas>
<canvas id="game" aria-label="Game world"></canvas> <canvas id="game" aria-label="Game world"></canvas>
<div id="ovframe" aria-hidden="true"><i class="c1"></i><i class="c2"></i><i class="c3"></i><i class="c4"></i></div> <div id="ovframe" aria-hidden="true"><i class="c1"></i><i class="c2"></i><i class="c3"></i><i class="c4"></i></div>
@@ -48,6 +49,7 @@
<div class="titlewrap"> <div class="titlewrap">
<canvas id="titleart" width="320" height="80" aria-label="Deepgrain Foundry"></canvas> <canvas id="titleart" width="320" height="80" aria-label="Deepgrain Foundry"></canvas>
<p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p> <p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p>
<div id="cap-notice" class="plate note" role="status" hidden></div>
<div class="tmenu" id="tmenu"> <div class="tmenu" id="tmenu">
<button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button> <button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button>
<button class="btn" id="btn-worlds" hidden>Worlds <small>load · rename · delete</small></button> <button class="btn" id="btn-worlds" hidden>Worlds <small>load · rename · delete</small></button>
+2 -2
View File
@@ -60,7 +60,7 @@ function toggleGateAt(x, y) {
const m = mat[i]; const m = mat[i];
if (m !== EMPTY && PH[m] !== PH_GAS) { if (m !== EMPTY && PH[m] !== PH_GAS) {
const u = i - W; const u = i - W;
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; } if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; renderDirtyIdx(u); }
} }
part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0; part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0;
} }
@@ -82,7 +82,7 @@ function interactAt(x, y) {
return open ? 'Rack open.' : 'Rack closed.'; return open ? 'Rack open.' : 'Rack closed.';
} }
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) { if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' }); blockV[pb] ^= 4; renderDirtyXY((pb % BW) * BS, ((pb / BW) | 0) * BS); emit({ t: 'sfx', s: 'ui' });
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.'); return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
} }
const id = mach[y * W + x]; const id = mach[y * W + x];
+2
View File
@@ -83,6 +83,7 @@ function placeBlock(code, v, bx, by, free, extra) {
else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0); else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0);
} }
if (isPipeCode(code)) pipesDirty = true; if (isPipeCode(code)) pipesDirty = true;
renderDirtyXY(bx * BS, by * BS);
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v; blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; } if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
if (isProcCode(code)) procDirty = true; if (isProcCode(code)) procDirty = true;
@@ -169,6 +170,7 @@ function removeBlock(bx, by) {
rackState.delete(b); rackState.delete(b);
if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y); if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y);
} }
renderDirtyXY(bx * BS, by * BS);
blockP[b] = 0; blockV[b] = 0; blockP[b] = 0; blockV[b] = 0;
const cleared = blockCellList(bx, by); const cleared = blockCellList(bx, by);
if (isSlope(code) || isSlopeBelt(code)) { if (isSlope(code) || isSlopeBelt(code)) {
+34 -36
View File
@@ -12,6 +12,9 @@ function setCell(i, m, t, fl) {
if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y); if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y);
} }
function swapCells(i, j) { function swapCells(i, j) {
renderDirtyIdx(i); renderDirtyIdx(j); swapCellData(i, j);
}
function swapCellData(i, j) {
let a = mat[i]; mat[i] = mat[j]; mat[j] = a; let a = mat[i]; mat[i] = mat[j]; mat[j] = a;
const f = temp[i]; temp[i] = temp[j]; temp[j] = f; const f = temp[i]; temp[i] = temp[j]; temp[j] = f;
a = life[i]; life[i] = life[j]; life[j] = a; a = life[i]; life[i] = life[j]; life[j] = a;
@@ -19,16 +22,16 @@ function swapCells(i, j) {
stamp[i] = tickStamp; stamp[j] = tickStamp; stamp[i] = tickStamp; stamp[j] = tickStamp;
} }
function move(i, j, x, y, nx, ny) { function move(i, j, x, y, nx, ny) {
swapCells(i, j); swapCellData(i, j);
wakeXY(x, y); wakeXY(nx, ny); wakeXY(x, y); wakeXY(nx, ny);
if (ny === y && LFILM > 1 && PH[mat[j]] === PH_LIQUID && (y === 0 || PH[mat[i - W]] !== PH_LIQUID)) { if (ny === y && LFILM > 1 && PH[mat[j]] === PH_LIQUID && (y === 0 || PH[mat[i - W]] !== PH_LIQUID)) {
// a top-layer grain stepping sideways may clear the path to a drop for surface grains far // a top-layer grain stepping sideways may clear the path to a drop for surface grains far
// along the row, so wake them; moves inside a body of water do not (settled lakes must sleep) // along the row, so wake them; moves inside a body of water do not (settled lakes must sleep)
const reach = SPREAD[mat[j]] * LFILM, row = (y >> 5) * CW; const reach = SPREAD[mat[j]] * LFILM, row = (y >> 5) * CW;
const first = Math.max(0, x - reach) >> 5, last = Math.min(W - 1, x + reach) >> 5; const first = Math.max(0, x - reach) >> 5, last = Math.min(W - 1, x + reach) >> 5;
for (let cx = first; cx <= last; cx++) awake[row + cx] = 3; for (let cx = first; cx <= last; cx++) chunkWake(row + cx);
} }
if (part[j] === P_WHEEL) { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; } if (part[j] === P_WHEEL) { if (chunkWork) chunkEvent('flow', mach[j], PH[mat[j]] === PH_LIQUID ? 1 : 0.5); else { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; } }
const d = temp[j] - AMB[ny]; const d = temp[j] - AMB[ny];
if (d > 25 || d < -25) wakeHeatXY(nx, ny); if (d > 25 || d < -25) wakeHeatXY(nx, ny);
} }
@@ -82,7 +85,7 @@ function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
// a grain at least twice as dense works its way down through a loose pile. Unlit fuel // a grain at least twice as dense works its way down through a loose pile. Unlit fuel
// packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in. // packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
const t = mat[b]; const t = mat[b];
if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; } if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); chunkWake((y >> 5) * CW + (x >> 5), 40); return; }
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge // a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
if ((t === BELTL || t === BELTR || part[b] === P_FILTER) && if ((t === BELTL || t === BELTR || part[b] === P_FILTER) &&
!isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return; !isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return;
@@ -131,11 +134,18 @@ function liquid(i, x, y, m) {
if (rnd() > FLUID[m]) return; if (rnd() > FLUID[m]) return;
const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1); const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1);
const d0 = (RS & 4) ? 1 : -1; const d0 = (RS & 4) ? 1 : -1;
for (let pass = 0; pass < 2; pass++) { liquidSearch(i, x, y, m, sp, d0);
}
function liquidSearch(i, x, y, m, sp, d0, firstPass = 0, firstStep = 1) {
for (let pass = firstPass; pass < 2; pass++) {
const d = pass ? -d0 : d0; const d = pass ? -d0 : d0;
for (let s = 1; s <= sp; s++) { for (let s = pass === firstPass ? firstStep : 1; s <= sp; s++) {
const nx = x + d * s; const nx = x + d * s;
if (nx < 0 || nx >= W) break; if (nx < 0 || nx >= W) break;
// Same-colour chunks are separated by 32 cells; their maximum write halo is 8.
if (chunkWork && (nx < chunkWork.x0 - 24 || nx >= chunkWork.x1 + 24)) {
chunkEvent('liquid', i, m, sp, d0, pass, s); return;
}
const j = i + d * s; const j = i + d * s;
if (!passable(m, PH_LIQUID, j, d, 0)) break; if (!passable(m, PH_LIQUID, j, d, 0)) break;
if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) { if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) {
@@ -148,7 +158,7 @@ function liquid(i, x, y, m) {
} }
function gas(i, x, y, m) { function gas(i, x, y, m) {
// Even blocked gas must keep aging or condensing until it is gone. // Even blocked gas must keep aging or condensing until it is gone.
awake[(y >> 5) * CW + (x >> 5)] = 3; chunkWake((y >> 5) * CW + (x >> 5));
if (m === FIRE) { if (m === FIRE) {
const l = life[i]; const l = life[i];
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; } if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
@@ -191,35 +201,23 @@ function wakeBotPowder() {
if (dx * dx + dy * dy <= 10000) awake[c] = 1; if (dx * dx + dy * dy <= 10000) awake[c] = 1;
} }
} }
function simMove() { function simMove() { chunkPasses(false); }
for (let cy = CH - 1; cy >= 0; cy--) { function moveChunk(c) {
const rowBase = cy * CW; const cx = c % CW, cy = (c / CW) | 0;
let any = false; const x0 = cx << 5, x1 = Math.min(W, x0 + 32), y0 = cy << 5, y1 = Math.min(H, y0 + 32);
for (let cx = 0; cx < CW; cx++) if (awake[rowBase + cx]) { any = true; powderArea[rowBase + cx] = 0; } const barriers = cleatChunks[c];
if (!any) continue; powderArea[c] = 0; roofDirty[cx] = 1; renderDirtyChunk(c);
const y0 = cy << 5, y1 = Math.min(H, y0 + 32); for (let y = y1 - 1; y >= y0; y--) {
for (let y = y1 - 1; y >= y0; y--) { const ltr = ((y + tick) & 1) === 0, dx = ltr ? 1 : -1, end = ltr ? x1 : x0 - 1, yw = y * W;
const ltr = ((y + tick) & 1) === 0; for (let x = ltr ? x0 : x1 - 1; x !== end; x += dx) {
const yw = y * W; const i = yw + x, m = mat[i];
for (let k = 0; k < CW; k++) { if (m === EMPTY) continue;
const cx = ltr ? k : CW - 1 - k; const ph = PH[m];
if (!awake[rowBase + cx]) continue; if (ph === PH_POWDER) powderArea[c] = 1;
roofDirty[cx] = 1; if (stamp[i] === tickStamp || ph === PH_SOLID) continue;
const x0 = cx << 5, x1 = Math.min(W, x0 + 32), barriers = cleatChunks[rowBase + cx]; if (IS_SCREW[part[i]] && ph === PH_POWDER) continue;
// alternate rows scan left to right and right to left, so nothing drifts one way if (ph === PH_POWDER) powder(i, x, y, m, barriers);
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1; else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
const i = yw + x, m = mat[i];
if (m === EMPTY) continue;
const ph = PH[m];
if (ph === PH_POWDER) powderArea[rowBase + cx] = 1;
if (stamp[i] === tickStamp) continue;
if (ph === PH_SOLID) continue;
const p = part[i];
if (IS_SCREW[p] && ph === PH_POWDER) continue; // a screw conveyor moves its own grains
if (ph === PH_POWDER) powder(i, x, y, m, barriers); else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
}
}
} }
} }
} }
+1
View File
@@ -114,6 +114,7 @@ function carryBeltsAndFilters(belts, filters, d, bm, carrying) {
} }
// Change what a filter does; grains inside it that no longer pass are pushed out on top. // Change what a filter does; grains inside it that no longer pass are pushed out on top.
function setFilter(b, cfg) { function setFilter(b, cfg) {
renderDirtyXY((b % BW) * BS, ((b / BW) | 0) * BS);
if (!isFilter(blockP[b])) return false; if (!isFilter(blockP[b])) return false;
const rec = filterRec(cfg); const rec = filterRec(cfg);
filterCfg.set(b, rec); filterCfg.set(b, rec);
+1 -1
View File
@@ -151,7 +151,7 @@ function pulseTick() {
const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1; const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1;
if (h > tier.hard) { e *= 0.35; continue; } if (h > tier.hard) { e *= 0.35; continue; }
const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6); const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6);
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else digp[i] = q; if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else { digp[i] = q; renderDirtyIdx(i); }
e *= p.tune ? 0.96 : p.damp; e *= p.tune ? 0.96 : p.damp;
} }
p.en[k] = e; p.s[k] = sd; p.en[k] = e; p.s[k] = sd;
+4 -4
View File
@@ -3,11 +3,11 @@ const FOG = 2;
const FOG_SENSE = 24; // Reveal nearby ground through rock, in grains. const FOG_SENSE = 24; // Reveal nearby ground through rock, in grains.
const FOG_SENSE_FADE = 12; const FOG_SENSE_FADE = 12;
let fog, FW, FH; let fog, FW, FH;
function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = new Uint8Array(FW * FH); } function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = sharedArray(Uint8Array, FW * FH); }
function fogMark(x, y, v) { function fogMark(x, y, v) {
if (x < 0 || y < 0 || x >= W || y >= H) return; if (x < 0 || y < 0 || x >= W || y >= H) return;
const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG); const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG);
if (v > fog[i]) fog[i] = v; if (v > fog[i]) { fog[i] = v; renderDirtyXY(x, y); }
} }
function fogSeen(x, y) { function fogSeen(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return 255; if (x < 0 || y < 0 || x >= W || y >= H) return 255;
@@ -39,7 +39,7 @@ function fogLook(cx, cy) {
const dx = (fx + 0.5) * FOG - cx, dy = (fy + 0.5) * FOG - cy, d2 = dx * dx + dy * dy, i = fy * FW + fx; const dx = (fx + 0.5) * FOG - cx, dy = (fy + 0.5) * FOG - cy, d2 = dx * dx + dy * dy, i = fy * FW + fx;
if (d2 >= R2) continue; if (d2 >= R2) continue;
const v = d2 <= in2 ? 255 : (FOG_SENSE - Math.sqrt(d2)) * 255 / FOG_SENSE_FADE; const v = d2 <= in2 ? 255 : (FOG_SENSE - Math.sqrt(d2)) * 255 / FOG_SENSE_FADE;
if (v > fog[i]) fog[i] = v; if (v > fog[i]) { fog[i] = v; renderDirtyXY(fx * FOG, fy * FOG); }
} }
} }
function fogRevealAround(x, y, r) { function fogRevealAround(x, y, r) {
@@ -49,4 +49,4 @@ function fogRevealAround(x, y, r) {
fogMark(xx, yy, r >= 6 && d2 <= (r - 6) ** 2 ? 255 : Math.min(255, (r - Math.sqrt(d2)) * 255 / 6)); fogMark(xx, yy, r >= 6 && d2 <= (r - 6) ** 2 ? 255 : Math.min(255, (r - Math.sqrt(d2)) * 255 / 6));
} }
} }
function fogRevealAll() { fog.fill(255); } function fogRevealAll() { fog.fill(255); renderDirty.fill(1); }
+14 -14
View File
@@ -52,19 +52,17 @@ function condPair(a, b, sc, nc, da) {
const q = d * k * sc; const q = d * k * sc;
temp[a] -= q * ICAP[ma]; temp[a] -= q * ICAP[ma];
temp[b] += q * ICAP[mb]; temp[b] += q * ICAP[mb];
if (nc >= 0 && (q > 0.15 || q < -0.15)) { if (!hot[nc]) hotLvl[nc] = 2; hot[nc] = 6; } if (nc >= 0 && (q > 0.15 || q < -0.15)) { chunkHeat(nc, !hot[nc] ? 2 : 0); }
}
function heatPass() {
for (let c = 0; c < NCH; c++) {
if (!hot[c]) continue;
let sc = 1;
if (hotLvl[c] === 1) { if (((tick + c) & 3) !== 0) continue; sc = 4; }
heatChunk(c, sc);
}
} }
function heatPass() { chunkPasses(true); }
function heatChunk(c, sc) { function heatChunk(c, sc) {
const cx = c % CW, cy = (c / CW) | 0; const cx = c % CW, cy = (c / CW) | 0;
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32); const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
renderDirty[c] = 1;
if (cx > 0) renderDirty[c - 1] = 1;
if (cx + 1 < CW) renderDirty[c + 1] = 1;
if (cy > 0) renderDirty[c - CW] = 1;
if (cy + 1 < CH) renderDirty[c + CW] = 1;
roofDirty[cx] = 1; roofDirty[cx] = 1;
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW]; const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
let maxDev = 0, burning = false; let maxDev = 0, burning = false;
@@ -88,7 +86,7 @@ function heatChunk(c, sc) {
if ((pu === PH_EMPTY || pu === PH_GAS) && ex > 2) { if ((pu === PH_EMPTY || pu === PH_GAS) && ex > 2) {
const a = ex * (sc > 1 ? 0.45 : 0.25); const a = ex * (sc > 1 ? 0.45 : 0.25);
t -= a; temp[u] += a; t -= a; temp[u] += a;
if (y === y0 && upCold && a > 0.5) { hot[c - CW] = 6; hotLvl[c - CW] = 2; } if (y === y0 && upCold && a > 0.5) { chunkHeat(c - CW); }
} }
} }
rel = y < roofY[x] ? 0.02 : 0.002; rel = y < roofY[x] ? 0.02 : 0.002;
@@ -105,8 +103,10 @@ function heatChunk(c, sc) {
if (flags[i] & F_BURN) burning = true; if (flags[i] & F_BURN) burning = true;
} }
} }
if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; } const h = burning || maxDev > 1.5 ? 6 : hot[c] - 1;
else hot[c]--; const level = burning || maxDev > 1.5 ? ((burning || maxDev > 60) ? 2 : 1) : hotLvl[c];
if (chunkWork) { chunkWork.ownHot[c] = h; chunkWork.ownLevel[c] = level; }
else { hot[c] = h; hotLvl[c] = level; }
} }
function neighborDir(i, x, y, d) { function neighborDir(i, x, y, d) {
if (d === 0) return y > 0 ? i - W : -1; if (d === 0) return y > 0 ? i - W : -1;
@@ -120,7 +120,7 @@ function react(i, x, y, m) {
if (fl & F_BURN) { burn(i, x, y, m, t); return; } if (fl & F_BURN) { burn(i, x, y, m, t); return; }
if (IGN[m] !== 0 && t >= IGN[m] && (hasO2(i, x, y) || burningNear(i, x, y))) { if (IGN[m] !== 0 && t >= IGN[m] && (hasO2(i, x, y) || burningNear(i, x, y))) {
flags[i] = fl | F_BURN; life[i] = FUEL[m]; flags[i] = fl | F_BURN; life[i] = FUEL[m];
stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t); if (chunkWork) chunkEvent('ignite'); else stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t);
return; return;
} }
const ph = PH[m]; const ph = PH[m];
@@ -178,7 +178,7 @@ function react(i, x, y, m) {
if (rnd() < 0.03 + (t - mp) * 0.002) { if (rnd() < 0.03 + (t - mp) * 0.002) {
let to = MELTTO[m]; let to = MELTTO[m];
if (SMELTP[m] !== 0 && rnd() >= SMELTP[m]) to = t >= FRZ[MSLAG] ? MSLAG : SLAG; // the waste runs off molten when it is hot enough if (SMELTP[m] !== 0 && rnd() >= SMELTP[m]) to = t >= FRZ[MSLAG] ? MSLAG : SLAG; // the waste runs off molten when it is hot enough
if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) brokenMachines.push(mach[i]); } if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) { if (chunkWork) chunkEvent('broken', mach[i]); else brokenMachines.push(mach[i]); } }
mat[i] = to; flags[i] = 0; life[i] = PH[to] === PH_LIQUID ? 255 : 0; digp[i] = 0; mat[i] = to; flags[i] = 0; life[i] = PH[to] === PH_LIQUID ? 255 : 0; digp[i] = 0;
wakeXY(x, y); discover(to, x, y, m, SMELTP[m] !== 0 ? 'smelt' : 'melt', mp); wakeXY(x, y); discover(to, x, y, m, SMELTP[m] !== 0 ? 'smelt' : 'melt', mp);
} }
+2 -2
View File
@@ -5,7 +5,7 @@ function markPow(i, on) {
else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; } else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; }
powerDirty = true; powerDirty = true;
} }
function partLost(i) { markPow(i, false); } function partLost(i) { if (chunkWork) chunkEvent('lost', i); else markPow(i, false); }
let nextPowerBuild = 0; let nextPowerBuild = 0;
function rebuildPower() { function rebuildPower() {
powerDirty = false; powerDirty = false;
@@ -57,7 +57,7 @@ function netSat(i) { if (POWER_FREE) return 1; const n = nets[netId[i]]; return
// Move loose grains out of the given cells, to the nearest open cell above // Move loose grains out of the given cells, to the nearest open cell above
// the footprint in the same column (they then fall back on top). Gas just vanishes. // the footprint in the same column (they then fall back on top). Gas just vanishes.
function evictCells(cells, topY) { function evictCells(cells, topY) {
for (const i of cells) bgm[i] = 0; for (const i of cells) { bgm[i] = 0; renderDirtyIdx(i); }
for (const i of cells) { for (const i of cells) {
const m = mat[i], ph = PH[m]; const m = mat[i], ph = PH[m];
if (m === EMPTY || ph === PH_SOLID) continue; if (m === EMPTY || ph === PH_SOLID) continue;
+4 -2
View File
@@ -4,6 +4,7 @@
// no longer reaches the ground comes down: every trunk cell drops as wood chunks. // no longer reaches the ground comes down: every trunk cell drops as wood chunks.
function cutTree(i, x, y, tier, amt = 1) { function cutTree(i, x, y, tier, amt = 1) {
const b = bgm[i]; const b = bgm[i];
renderDirtyIdx(i);
const p = digp[i] + amt * tier.speed * 70 / (1 + HARD[b]); const p = digp[i] + amt * tier.speed * 70 / (1 + HARD[b]);
if (p < 255) { digp[i] = p; return false; } if (p < 255) { digp[i] = p; return false; }
digp[i] = 0; bgm[i] = 0; digp[i] = 0; bgm[i] = 0;
@@ -104,6 +105,7 @@ function plantTick() {
if (y <= 6 || y >= H - 1) continue; if (y <= 6 || y >= H - 1) continue;
const i = y * W + x; const i = y * W + x;
if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue; if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue;
renderDirtyIdx(i);
if (!SOIL[mat[i + W]] || temp[i] < 2 || temp[i] > 45) { life[i] = 0; continue; } if (!SOIL[mat[i + W]] || temp[i] < 2 || temp[i] > 45) { life[i] = 0; continue; }
let room = true; let room = true;
for (let k = 1; k <= 6 && room; k++) { const j = i - k * W; if (PH[mat[j]] !== PH_EMPTY && PH[mat[j]] !== PH_GAS || bgm[j] === WOOD) room = false; } for (let k = 1; k <= 6 && room; k++) { const j = i - k * W; if (PH[mat[j]] !== PH_EMPTY && PH[mat[j]] !== PH_GAS || bgm[j] === WOOD) room = false; }
@@ -120,7 +122,7 @@ function setTreeCell(x, y, m) {
const i = y * W + x; const i = y * W + x;
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return; if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return;
if (m === LEAVES && bgm[i]) return; if (m === LEAVES && bgm[i]) return;
bgm[i] = m; bgm[i] = m; renderDirtyIdx(i);
if (treeDraw) treeDraw.push(i); if (treeDraw) treeDraw.push(i);
} }
function saplingTick() { function saplingTick() {
@@ -134,7 +136,7 @@ function saplingTick() {
sp.age++; sp.age++;
const s = Math.min(1, sp.age / 180); const s = Math.min(1, sp.age / 180);
// wipe the previous stage, then draw this one // wipe the previous stage, then draw this one
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) bgm[i] = 0; if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) { bgm[i] = 0; renderDirtyIdx(i); }
treeDraw = sp.cells = []; treeDraw = sp.cells = [];
const th = Math.max(1, Math.round(sp.th * s)), wide = s > 0.5 ? 2 : 1; const th = Math.max(1, Math.round(sp.th * s)), wide = s > 0.5 ? 2 : 1;
for (let j = 1; j <= th; j++) for (let dx = 0; dx < wide; dx++) setTreeCell(sp.x + dx, sp.y - j, WOOD); for (let j = 1; j <= th; j++) for (let dx = 0; dx < wide; dx++) setTreeCell(sp.x + dx, sp.y - j, WOOD);
+1 -1
View File
@@ -50,7 +50,7 @@ function toolDig(cx, cy, r) {
if (isWall(b) && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) removeBlock((x / BS) | 0, (y / BS) | 0); if (isWall(b) && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) removeBlock((x / BS) | 0, (y / BS) | 0);
dug++; dug++;
if ((dug & 3) === 0) emit({ t: 'debris', x, y, m }); if ((dug & 3) === 0) emit({ t: 'debris', x, y, m });
} else digp[i] = p; } else { digp[i] = p; renderDirtyIdx(i); }
}); });
stats.dug += dug; stats.dug += dug;
let status = ''; let status = '';
+1 -1
View File
@@ -25,7 +25,7 @@ function worldWater() {
for (const mm of machines) if (mm && mm.type === 'silo' && mm.sm === SNOW) n += mm.sn; // and snow in silos for (const mm of machines) if (mm && mm.type === 'silo' && mm.sm === SNOW) n += mm.sn; // and snow in silos
return Math.round(n + bag[WATER] + bag[SNOW]); return Math.round(n + bag[WATER] + bag[SNOW]);
} }
function absorbSteam(x) { const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; } function absorbSteam(x) { if (chunkWork) { chunkEvent('moist', x); return; } const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
// Send about k drops (snowflakes where it is freezing) down from the clouds over x0..x1; returns how many fell // Send about k drops (snowflakes where it is freezing) down from the clouds over x0..x1; returns how many fell
function spawnRain(x0, x1, k) { function spawnRain(x0, x1, k) {
if (flyers.length > 2500) return 0; if (flyers.length > 2500) return 0;
+153
View File
@@ -0,0 +1,153 @@
// The same chunk schedule runs inline or in workers. Workers only own a pass at a time.
let simPool = null, chunkWork = null, inlineWork = null;
function threadWorld() {
return { W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
powderArea,roofDirty,renderDirty,AMB,roofY };
}
function threadBind(s) {
({ W,H,N,CW,CH,NCH,BW,BH,NB,mat,temp,life,flags,stamp,part,mach,digp,noiseB,bgm,
airS,netId,isPow,occ,cleatMask,cleatChunks,blockP,blockV,slopePhase,awake,hot,hotLvl,
powderArea,roofDirty,renderDirty,AMB,roofY } = s);
}
function threadContext() {
return { wake: new Uint8Array(NCH), heat: new Uint8Array(NCH), level: new Uint8Array(NCH),
ownHot: new Uint8Array(NCH).fill(255), ownLevel: new Uint8Array(NCH), events: [], c: -1,
x0: 0, x1: 0 };
}
function resetChunkWork(work) {
work.wake.fill(0); work.heat.fill(0); work.level.fill(0); work.ownHot.fill(255); work.events.length = 0;
return work;
}
function inlineChunkWork() {
if (!inlineWork || inlineWork.wake.length !== NCH) inlineWork = threadContext();
return resetChunkWork(inlineWork);
}
function chunkEvent(kind, ...args) { chunkWork.events.push([chunkWork.c, kind, ...args]); }
function chunkWake(c, n = 3) {
if (chunkWork) { if (chunkWork.wake[c] < n) chunkWork.wake[c] = n; }
else awake[c] = n;
}
function chunkHeat(c, level = 2) {
if (chunkWork) { chunkWork.heat[c] = 6; if (chunkWork.level[c] < level) chunkWork.level[c] = level; }
else { hot[c] = 6; if (level) hotLvl[c] = level; }
}
function chunkSeed(c, pass) { return hash2(c, tick, worldSeed ^ Math.imul(pass + 1, 0x9e3779b9)) | 1; }
function runChunks(chunks, pass, work) {
const savedRS = RS;
chunkWork = work;
try {
for (const c of chunks) {
work.c = c; work.x0 = (c % CW) * 32; work.x1 = Math.min(W, work.x0 + 32);
RS = chunkSeed(c, pass);
if (pass < 4) moveChunk(c); else {
const sc = hotLvl[c] === 1 ? 4 : 1;
heatChunk(c, sc);
}
}
} finally { chunkWork = null; RS = savedRS; }
}
function mergeChunkWork(works) {
for (const work of works) for (let c = 0; c < NCH; c++) if (work.ownHot[c] !== 255) {
hot[c] = work.ownHot[c]; hotLvl[c] = work.ownLevel[c];
}
for (const work of works) for (let c = 0; c < NCH; c++) {
if (awake[c] < work.wake[c]) awake[c] = work.wake[c];
if (hot[c] < work.heat[c]) hot[c] = work.heat[c];
if (hotLvl[c] < work.level[c]) hotLvl[c] = work.level[c];
}
const events = works.flatMap(w => w.events).sort((a,b) => a[0] - b[0]);
for (const [,kind,...a] of events) {
if (kind === 'discover') discover(...a);
else if (kind === 'emit') emit(a[0]);
else if (kind === 'flow') { const mm = machines[a[0]]; if (mm) mm.flow += a[1]; }
else if (kind === 'moist') absorbSteam(a[0]);
else if (kind === 'lost') partLost(a[0]);
else if (kind === 'broken') brokenMachines.push(a[0]);
else if (kind === 'ignite') stats.ignitions++;
else if (kind === 'liquid') {
const i = a[0];
if (stamp[i] !== tickStamp && mat[i] === a[1]) liquidSearch(i, i % W, (i / W) | 0, ...a.slice(1));
}
}
}
function chunkPasses(heat) {
for (let color = 0; color < 4; color++) {
const chunks = [];
for (let c = 0; c < NCH; c++) {
if (((c % CW & 1) | (((c / CW) & 1) << 1)) !== color) continue;
if (heat ? (!hot[c] || (hotLvl[c] === 1 && ((tick + c) & 3) !== 0)) : !awake[c]) continue;
chunks.push(c);
}
if (!chunks.length) continue;
const pass = color + (heat ? 4 : 0);
if (simPool && simPool.world === mat && chunks.length >= (simPool.force ? 1 : (heat ? 16 : 64))) threadPass(simPool, chunks, pass);
else { const work = inlineChunkWork(); runChunks(chunks, pass, work); mergeChunkWork([work]); }
}
}
function threadParams() {
return { tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,
seen, bot: {...bot}, skyY: weather.skyY, barriers: !!cleatCells.length, filterCfg };
}
function threadSetParams(s) {
({tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,filterCfg} = s);
seen.set(s.seen); Object.assign(bot, s.bot);
weather.skyY = s.skyY; cleatCells = s.barriers ? [1] : [];
}
// Transport is injected by the browser or Node harness; no browser globals in the simulation.
function threadSlot() {
// shortcut: 1 MiB of events per helper per pass; grow or stream it if dense reactions fill it.
return { control: sharedArray(Int32Array, 2), bytes: sharedArray(Uint8Array, 1024 * 1024),
wake: sharedArray(Uint8Array, NCH), heat: sharedArray(Uint8Array, NCH),
level: sharedArray(Uint8Array, NCH), ownHot: sharedArray(Uint8Array, NCH),
ownLevel: sharedArray(Uint8Array, NCH) };
}
function threadExecute(slot, chunks, pass) {
slot.wake.fill(0); slot.heat.fill(0); slot.level.fill(0); slot.ownHot.fill(255);
const work = { ...slot, events: [], c: -1 };
runChunks(chunks, pass, work);
const encoded = new TextEncoder().encode(JSON.stringify(work.events));
if (encoded.length > slot.bytes.length) throw Error('Simulation event buffer full');
slot.bytes.set(encoded); Atomics.store(slot.control, 1, encoded.length);
}
function threadPass(pool, chunks, pass) {
const params = threadParams(), n = Math.min(pool.slots.length, (pool.force ? chunks.length : Math.max(1, Math.floor(chunks.length / (pass < 4 ? 32 : 8)))));
pool.activeMax = Math.max(pool.activeMax || 0, n);
const batches = Array.from({length:n + 1}, () => []);
for (let k = 0; k < chunks.length; k++) batches[k % batches.length].push(chunks[k]);
for (let k = 0; k < n; k++) {
const slot = pool.slots[k]; Atomics.store(slot.control, 0, 0);
pool.workers[k].postMessage({ chunks: batches[k], pass, params });
}
const local = inlineChunkWork(); runChunks(batches[n], pass, local);
const works = [local], deadline = Date.now() + 10000;
for (let k = 0; k < n; k++) {
const slot = pool.slots[k];
threadWait(slot, deadline);
const events = JSON.parse(new TextDecoder().decode(slot.bytes.slice(0, Atomics.load(slot.control, 1))));
if (Atomics.load(slot.control, 0) < 0) throw Error('Simulation worker: ' + events);
works.push({ ...slot, events });
}
mergeChunkWork(works);
}
function threadWait(slot, deadline) {
let spins = 0;
// Atomics.wait is forbidden on the browser main thread. Bound polling to fail closed.
while (Atomics.load(slot.control, 0) === 0) {
if (++spins === 16384) { spins = 0; if (Date.now() > deadline) throw Error('Simulation worker timed out'); }
}
}
function threadFailure(slot, error) {
const bytes = new TextEncoder().encode(JSON.stringify(String(error.stack || error)));
slot.bytes.set(bytes.subarray(0, slot.bytes.length));
Atomics.store(slot.control, 1, Math.min(bytes.length, slot.bytes.length));
Atomics.store(slot.control, 0, -1); Atomics.notify(slot.control, 0);
}
function threadMessage(data) {
if (data.world) { threadBind(data.world); globalThis.threadWorkerSlot = data.slot; Atomics.store(data.slot.control, 0, 1); return; }
const slot = globalThis.threadWorkerSlot;
try {
threadSetParams(data.params); threadExecute(slot, data.chunks, data.pass);
Atomics.store(slot.control, 0, 1); Atomics.notify(slot.control, 0);
} catch (e) { threadFailure(slot, e); }
}
+32 -16
View File
@@ -13,6 +13,7 @@ let ents = []; // loose objects (bars)
let bgm; // background layer: trees. Liquids, powders and gases pass in front of it. let bgm; // background layer: trees. Liquids, powders and gases pass in front of it.
let flyers = []; // grains in flight, blown out of the bot let flyers = []; // grains in flight, blown out of the bot
let ghostBlocks = new Map(), ghostMolds = []; let ghostBlocks = new Map(), ghostMolds = [];
let renderDirty; // GPU world textures, one invalidation bit per 32x32 chunk.
let awake, hot, hotLvl, powderArea, roofDirty; let awake, hot, hotLvl, powderArea, roofDirty;
let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0; let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0;
let tick = 0, tickStamp = 1; let tick = 0, tickStamp = 1;
@@ -28,35 +29,41 @@ let seen = new Uint8Array(NMAT);
let ledger = {}; let ledger = {};
const stats = { dug: 0, moved: 0, placed: 0, ignitions: 0, cast: 0, bars: 0 }; const stats = { dug: 0, moved: 0, placed: 0, ignitions: 0, cast: 0, bars: 0 };
function emit(e) { if (EV.length < 400) EV.push(e); } function emit(e) { if (chunkWork) { chunkEvent('emit', e); return; } if (EV.length < 400) EV.push(e); }
function bagCap() { return TANK_TIERS[player.tankTier].cap; } function bagCap() { return TANK_TIERS[player.tankTier].cap; }
// Things count as discovered only when they happen near the player (or in the bag). // Things count as discovered only when they happen near the player (or in the bag).
// how: 'dig', 'pickup', 'melt', 'smelt', 'freeze', 'boil', 'ignite', 'burn', 'fire', 'blow', 'condense' // how: 'dig', 'pickup', 'melt', 'smelt', 'freeze', 'boil', 'ignite', 'burn', 'fire', 'blow', 'condense'
function discover(m, x, y, from, how, T) { function discover(m, x, y, from, how, T) {
if (seen[m]) return; if (seen[m]) return;
if (x !== undefined) { const dx = x - bot.x, dy = y - bot.y; if (dx * dx + dy * dy > 110 * 110) return; } if (x !== undefined) { const dx = x - bot.x, dy = y - bot.y; if (dx * dx + dy * dy > 110 * 110) return; }
if (chunkWork) { chunkEvent('discover', m, x, y, from, how, T); return; }
seen[m] = 1; emit({ t: 'discover', m, from: from || 0, how: how || '', T: T || 0 }); seen[m] = 1; emit({ t: 'discover', m, from: from || 0, how: how || '', T: T || 0 });
} }
function sharedArray(Type, length) {
const Buffer = typeof SharedArrayBuffer === 'function' ? SharedArrayBuffer : ArrayBuffer;
return new Type(new Buffer(length * Type.BYTES_PER_ELEMENT));
}
function alloc(w, h) { function alloc(w, h) {
resetDevConsole(); resetDevConsole();
W = w; H = h; N = w * h; W = w; H = h; N = w * h;
fogAlloc(); fogAlloc();
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH; CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
cleatMask = new Uint8Array(N); cleatCells = []; cleatMask = sharedArray(Uint8Array, N); cleatCells = [];
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N); mat = sharedArray(Uint8Array, N); temp = sharedArray(Float32Array, N); life = sharedArray(Uint8Array, N); flags = sharedArray(Uint8Array, N);
stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N); stamp = sharedArray(Uint8Array, N); part = sharedArray(Uint8Array, N); mach = sharedArray(Uint16Array, N); digp = sharedArray(Uint8Array, N);
noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N); noiseB = sharedArray(Uint8Array, N); bgm = sharedArray(Uint8Array, N); airS = sharedArray(Uint16Array, N); netId = sharedArray(Uint16Array, N); isPow = sharedArray(Uint8Array, N); occ = sharedArray(Uint8Array, N);
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); slopePhase = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear(); BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = sharedArray(Uint8Array, NB); blockV = sharedArray(Uint8Array, NB); slopePhase = sharedArray(Uint8Array, NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = []; ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = [];
surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36; surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36;
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w); surfY = sharedArray(Int16Array, w); roofY = sharedArray(Int16Array, w); dirtY = sharedArray(Int16Array, w); graniteY = sharedArray(Int16Array, w); basaltY = sharedArray(Int16Array, w); magmaY = sharedArray(Int16Array, w);
AMB = new Float32Array(h); AMB = sharedArray(Float32Array, h);
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0; loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
cleatChunks = new Uint8Array(NCH); renderDirty = sharedArray(Uint8Array, NCH).fill(1);
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH); cleatChunks = sharedArray(Uint8Array, NCH);
roofDirty = new Uint8Array(CW).fill(1); awake = sharedArray(Uint8Array, NCH); hot = sharedArray(Uint8Array, NCH); hotLvl = sharedArray(Uint8Array, NCH);
powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out roofDirty = sharedArray(Uint8Array, CW).fill(1);
powderArea = sharedArray(Uint8Array, NCH).fill(1); // unknown areas get one visit before being ruled out
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true; machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []); brokenMachines = []; beltL = []; beltR = []; screwLists = Array.from({ length: IS_SCREW.length }, () => []); brokenMachines = [];
ghostMachines = []; ghostBlocked = new Set(); ghostMachines = []; ghostBlocked = new Set();
@@ -90,18 +97,27 @@ function fillNoise() {
} }
// ---------- Waking ---------- // ---------- Waking ----------
function renderDirtyXY(x, y) { renderDirty[(y >> 5) * CW + (x >> 5)] = 1; }
function renderDirtyChunk(c) {
const cx = c % CW, cy = (c / CW) | 0;
for (let yy = Math.max(0, cy - 1); yy <= Math.min(CH - 1, cy + 1); yy++)
for (let xx = Math.max(0, cx - 1); xx <= Math.min(CW - 1, cx + 1); xx++) renderDirty[yy * CW + xx] = 1;
}
function renderDirtyIdx(i) { renderDirtyXY(i % W, (i / W) | 0); }
function wakeXY(x, y) { function wakeXY(x, y) {
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx; const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
if (!chunkWork) renderDirty[c] = 1;
roofDirty[cx] = 1; roofDirty[cx] = 1;
awake[c] = 3; chunkWake(c);
const lx = x & 31, ly = y & 31; const lx = x & 31, ly = y & 31;
if (lx === 0) { if (cx > 0) awake[c - 1] = 3; } else if (lx === 31 && cx < CW - 1) awake[c + 1] = 3; if (lx === 0) { if (cx > 0) chunkWake(c - 1); } else if (lx === 31 && cx < CW - 1) chunkWake(c + 1);
if (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3; if (ly === 0) { if (cy > 0) chunkWake(c - CW); } else if (ly === 31 && cy < CH - 1) chunkWake(c + CW);
} }
function wakeHeatXY(x, y) { function wakeHeatXY(x, y) {
const c = (y >> 5) * CW + (x >> 5); const c = (y >> 5) * CW + (x >> 5);
if (!chunkWork) renderDirty[c] = 1;
roofDirty[x >> 5] = 1; roofDirty[x >> 5] = 1;
hot[c] = 6; hotLvl[c] = 2; chunkHeat(c);
} }
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); } function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); } function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); }
+7 -2
View File
@@ -29,8 +29,7 @@ body {
font-family: var(--font-ui); font-size: calc(13.5px * var(--ui)); line-height: 1.4; font-family: var(--font-ui); font-size: calc(13.5px * var(--ui)); line-height: 1.4;
-webkit-user-select: none; user-select: none; touch-action: none; -webkit-user-select: none; user-select: none; touch-action: none;
} }
canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: crosshair; } canvas#gpu, canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: none; } /* the game draws its own aim */
canvas#game.nocursor { cursor: none; }
/* overlay modes (thermal lens, info overlay): a coloured frame with corner brackets, a light wash /* overlay modes (thermal lens, info overlay): a coloured frame with corner brackets, a light wash
for the info overlay so the world stays clear, and a named badge at the bottom of the screen */ for the info overlay so the world stays clear, and a named badge at the bottom of the screen */
#ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; } #ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; }
@@ -219,6 +218,7 @@ table.mt td.dip { color: var(--melt); }
#perf { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(12px, env(safe-area-inset-bottom, 0px)) + 4px); font-family: var(--font-mono); font-size: 11px; color: var(--ash); pointer-events: none; text-align: right; white-space: pre; } #perf { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(12px, env(safe-area-inset-bottom, 0px)) + 4px); font-family: var(--font-mono); font-size: 11px; color: var(--ash); pointer-events: none; text-align: right; white-space: pre; }
#hud .br { bottom: max(12px, env(safe-area-inset-bottom, 0px)); } #hud .br { bottom: max(12px, env(safe-area-inset-bottom, 0px)); }
#perf:not(:empty) { background: var(--plate); color: var(--bone); padding: 6px 8px; border-radius: 4px; }
#perf:not(:empty) ~ .br { } #perf:not(:empty) ~ .br { }
/* ---------- modals ---------- */ /* ---------- modals ---------- */
@@ -291,6 +291,8 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
#titleart { width: min(820px, 100%); aspect-ratio: 4 / 1; display: block; margin: 0 auto; image-rendering: pixelated; max-width: 100%; } #titleart { width: min(820px, 100%); aspect-ratio: 4 / 1; display: block; margin: 0 auto; image-rendering: pixelated; max-width: 100%; }
.titlewrap { align-self: center; display: flex; flex-direction: column; align-items: center; gap: 18px; padding-block: 24px; } .titlewrap { align-self: center; display: flex; flex-direction: column; align-items: center; gap: 18px; padding-block: 24px; }
.tagline { color: var(--ash); max-width: 52ch; text-align: center; margin: -6px 0 4px; text-wrap: balance; } .tagline { color: var(--ash); max-width: 52ch; text-align: center; margin: -6px 0 4px; text-wrap: balance; }
#cap-notice { max-width: 64ch; padding: 8px 14px; color: var(--melt-hot); }
#cap-notice p { margin: 6px 0; }
.tmenu { display: flex; flex-direction: column; gap: 8px; width: min(360px, 100%); } .tmenu { display: flex; flex-direction: column; gap: 8px; width: min(360px, 100%); }
.tmenu .btn { padding: 10px 14px; text-align: left; display: flex; justify-content: space-between; align-items: center; } .tmenu .btn { padding: 10px 14px; text-align: left; display: flex; justify-content: space-between; align-items: center; }
.tmenu .btn small { color: var(--ash); font-size: 0.82em; } .tmenu .btn small { color: var(--ash); font-size: 0.82em; }
@@ -397,3 +399,6 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
#hud.building:has(#minimap:not([hidden])) ~ #perf { bottom: calc(350px * var(--ui)); } #hud.building:has(#minimap:not([hidden])) ~ #perf { bottom: calc(350px * var(--ui)); }
#hud.building:has(#minimap:not([hidden])) ~ #saving { bottom: calc(390px * var(--ui)); } #hud.building:has(#minimap:not([hidden])) ~ #saving { bottom: calc(390px * var(--ui)); }
} }
canvas#gpu { pointer-events: none; }
canvas#gpu[hidden] { display: none; }
+2 -1
View File
@@ -89,7 +89,8 @@ async function openNewClaim() {
showLoading(true, 0.9, 'Saving'); showLoading(true, 0.9, 'Saving');
if (!await saveWorld('auto', true)) throw new Error('Save failed'); if (!await saveWorld('auto', true)) throw new Error('Save failed');
showLoading(true, 1, 'Saving'); showLoading(true, 1, 'Saving');
showLoading(false); closeAllModals(); enterGame(); await gpuWarmWorld();
showLoading(false); closeAllModals(); enterGame(true);
showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 }); showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 });
return true; return true;
} catch (e) { } catch (e) {
+47
View File
@@ -0,0 +1,47 @@
// Hover work is shared by the cursor label and the vein outline.
const CURSOR_ORES = new Uint8Array(NMAT);
for (let m = 0; m < NMAT; m++) if (PING_VEINS[m]) { CURSOR_ORES[m] = 1; CURSOR_ORES[YLD[m]] = 1; }
function oreLike(m, bar) { return !!(bar || CURSOR_ORES[m] || SOLIDMETAL[m]); }
function bitNeeded(m) {
if (HARD[m] > 6) return 'cannot be drilled';
return 'needs ' + BIT_TIERS.find(b => b.hard >= HARD[m]).name.toLowerCase();
}
function pingVisible(x, y) {
const p = GAME.ping;
if (!p || x < 0 || y < 0 || x >= W || y >= H) return false;
if (!p.cellSet) p.cellSet = new Set(p.cells);
if (!p.cellSet.has(y * W + x)) return false;
const start = Math.hypot(x - p.x, y - p.y) / PING_SPEED * 1000, ms = performance.now() - p.t0;
return ms >= start && ms < start + PING_HOLD + PING_FADE;
}
function cursorVein(i) {
const m = mat[i], cells = new Set([i]), queue = [i], edges = [];
const same = q => q >= 0 && q < N && mat[q] === m && (flags[q] & F_NAT);
const add = n => { if (cells.size < 6000 && same(n) && !cells.has(n)) { cells.add(n); queue.push(n); } };
for (let k = 0; k < queue.length; k++) {
const q = queue[k], x = q % W;
if (x) add(q - 1);
if (x < W - 1) add(q + 1);
add(q - W); add(q + W);
}
for (const q of cells) {
const x = q % W, y = (q / W) | 0;
if (!x || !same(q - 1)) edges.push([x, y, x, y + 1]);
if (x === W - 1 || !same(q + 1)) edges.push([x + 1, y, x + 1, y + 1]);
if (!same(q - W)) edges.push([x, y, x + 1, y]);
if (!same(q + W)) edges.push([x, y + 1, x + 1, y + 1]);
}
return { cells, edges, m, world: mat };
}
let cursorVeinCache = null, cursorLabelKey = null, cursorLabelSince = 0;
function hoveredVein(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return null;
const i = y * W + x, m = mat[i];
if (!PING_VEINS[m] || !(flags[i] & F_NAT) || (SET.fog && fogSeen(x, y) < 64 && !pingVisible(x, y))) return null;
if (!cursorVeinCache || cursorVeinCache.world !== mat || cursorVeinCache.m !== m || !cursorVeinCache.cells.has(i)) cursorVeinCache = cursorVein(i);
return cursorVeinCache;
}
function cursorLabelAlpha(key, now) {
if (key !== cursorLabelKey) { cursorLabelKey = key; cursorLabelSince = now; }
return Math.max(0, Math.min(1, (2400 - (now - cursorLabelSince)) / 400));
}
+1 -1
View File
@@ -10,7 +10,7 @@ function pollPad() {
const ax = v => Math.abs(v) < dz ? 0 : (v - Math.sign(v) * dz) / (1 - dz); const ax = v => Math.abs(v) < dz ? 0 : (v - Math.sign(v) * dz) / (1 - dz);
const lx = ax(gp.axes[0] || 0), ly = ax(gp.axes[1] || 0), rx = ax(gp.axes[2] || 0), ry = ax(gp.axes[3] || 0); const lx = ax(gp.axes[0] || 0), ly = ax(gp.axes[1] || 0), rx = ax(gp.axes[2] || 0), ry = ax(gp.axes[3] || 0);
const pressedAny = gp.buttons.some(b => b && b.pressed) || lx || ly || rx || ry; const pressedAny = gp.buttons.some(b => b && b.pressed) || lx || ly || rx || ry;
if (pressedAny && !GAME.usingPad) { GAME.usingPad = true; canvas.classList.add('nocursor'); } if (pressedAny) GAME.usingPad = true;
const modal = topModal(); const modal = topModal();
if (modal || !GAME.running) { if (modal || !GAME.running) {
const now = performance.now(); const now = performance.now();
+15 -8
View File
@@ -77,7 +77,8 @@ function reactionsOf(m, forNotebook) {
// What is under the cursor: t1 is the name, t2 the details, warn a short problem // What is under the cursor: t1 is the name, t2 the details, warn a short problem
// (too hard, out of reach) for the label next to the cursor. // (too hard, out of reach) for the label next to the cursor.
function hoverInfo(ax, ay) { function hoverInfo(ax, ay) {
if (SET.fog && fogSeen(ax, ay) < 64) return { t1: 'Unexplored', t2: [], t3: [] }; const pinged = pingVisible(ax, ay);
if (SET.fog && fogSeen(ax, ay) < 64 && !pinged) return { t1: 'Unexplored', t2: [], t3: [] };
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true }; if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true };
const i = ay * W + ax, m = mat[i]; const i = ay * W + ax, m = mat[i];
let warn = ''; let warn = '';
@@ -143,8 +144,8 @@ function hoverInfo(ax, ay) {
if (player.lens && !bar) t2.push(fmtT(temp[i])); if (player.lens && !bar) t2.push(fmtT(temp[i]));
if (!bar && m !== EMPTY && PH[m] === PH_SOLID && !PARTMAT[m] && (flags[i] & F_NAT)) { if (!bar && m !== EMPTY && PH[m] === PH_SOLID && !PARTMAT[m] && (flags[i] & F_NAT)) {
const ok = HARD[m] <= BIT_TIERS[player.bitTier].hard; const ok = HARD[m] <= BIT_TIERS[player.bitTier].hard;
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : `too hard (${HARD[m]})`); t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : bitNeeded(m));
if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard'; if (!ok && toolId() === 'drill') warn = bitNeeded(m);
} }
if (!bar && PH[m] === PH_POWDER) t2.push('loose'); if (!bar && PH[m] === PH_POWDER) t2.push('loose');
if (!id && !bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); } if (!id && !bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); }
@@ -163,7 +164,8 @@ function hoverInfo(ax, ay) {
// what it does, unless the cursor is on a machine or mold, which have their own details // what it does, unless the cursor is on a machine or mold, which have their own details
const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0; const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0;
const t3 = what ? reactionsOf(what) : []; const t3 = what ? reactionsOf(what) : [];
return { t1, t2, t3, warn, contents, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b }; if (pinged) t2.push('found by the ping');
return { t1, t2, t3, warn, contents, ore: oreLike(m, bar), m: bar ? bar.m : m, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
} }
function renderInfo() { function renderInfo() {
const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
@@ -173,15 +175,19 @@ function renderInfo() {
} }
// Name of what is under the cursor, drawn right next to it // Name of what is under the cursor, drawn right next to it
function drawCursorLabel(px, py, r) { function drawCursorLabel(px, py, r) {
if (!SET.cursorLabel || (!GAME.mouse.in && !GAME.usingPad)) return; if (SET.cursorLabel === 'off' || (!GAME.mouse.in && !GAME.usingPad)) { cursorLabelKey = null; return; }
const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!info.t1 || info.air) return; if (!info.t1 || (info.air && !info.warn) || (SET.cursorLabel === 'ores' && !info.ore && !info.warn)) { cursorLabelKey = null; return; }
const vein = hoveredVein(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
const alpha = cursorLabelAlpha(vein || info.m || info.t1, performance.now());
if (!alpha && !info.warn) return;
const ctx = R.ctx, dpr = R.canvas.width / window.innerWidth; const ctx = R.ctx, dpr = R.canvas.width / window.innerWidth;
const fs = Math.round(12 * dpr * SET.uiScale); const fs = Math.round(12 * dpr * SET.uiScale);
if (!R.uiFont) R.uiFont = getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif'; if (!R.uiFont) R.uiFont = getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif';
const main = info.t1 + (info.contents && info.warn ? ` ${info.contents}` : ''); const main = alpha ? info.t1 + (info.t2.includes('found by the ping') ? ' · found by the ping' : '') + (info.contents && info.warn ? ` ${info.contents}` : '') : '';
const sub = info.warn || info.contents || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : ''); const sub = info.warn || info.contents || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
ctx.save(); ctx.save();
ctx.globalAlpha = info.warn ? 1 : alpha;
ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.font = `600 ${fs}px ${R.uiFont}`;
const w1 = ctx.measureText(main).width; const w1 = ctx.measureText(main).width;
ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`;
@@ -193,7 +199,8 @@ function drawCursorLabel(px, py, r) {
ctx.fillStyle = 'rgba(20,17,15,0.82)'; ctx.fillStyle = 'rgba(20,17,15,0.82)';
ctx.beginPath(); ctx.roundRect(x, y, w1 + w2 + padX * 2, h, 4 * dpr); ctx.fill(); ctx.beginPath(); ctx.roundRect(x, y, w1 + w2 + padX * 2, h, 4 * dpr); ctx.fill();
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.fillText(main, x + padX, y + h / 2); ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.globalAlpha = alpha; ctx.fillText(main, x + padX, y + h / 2);
ctx.globalAlpha = info.warn ? 1 : alpha;
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : info.contents ? '#efe6d8' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); } if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : info.contents ? '#efe6d8' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
ctx.restore(); ctx.restore();
} }
+1 -1
View File
@@ -49,7 +49,7 @@ window.addEventListener('keydown', e => {
}); });
window.addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; keys.delete(k); if ((k === 'Control' || k === 'Meta') && GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } if (k === 'g') { GAME.replace = false; hudDirty = true; } if (k === 'c' || k === 'x') hudDirty = true; }); window.addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; keys.delete(k); if ((k === 'Control' || k === 'Meta') && GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } if (k === 'g') { GAME.replace = false; hudDirty = true; } if (k === 'c' || k === 'x') hudDirty = true; });
window.addEventListener('blur', () => { keys.clear(); if (GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } GAME.lmb = GAME.rmb = false; GAME.replace = false; GAME.selDrag = null; endRmbRecord(); }); window.addEventListener('blur', () => { keys.clear(); if (GAME.ctrl) { GAME.ctrl = false; lastBagKey = ''; hudDirty = true; } GAME.lmb = GAME.rmb = false; GAME.replace = false; GAME.selDrag = null; endRmbRecord(); });
canvas.addEventListener('mousemove', e => { GAME.mouse.x = e.clientX; GAME.mouse.y = e.clientY; GAME.mouse.in = true; if (GAME.usingPad) { GAME.usingPad = false; canvas.classList.remove('nocursor'); } }); canvas.addEventListener('mousemove', e => { GAME.mouse.x = e.clientX; GAME.mouse.y = e.clientY; GAME.mouse.in = true; GAME.usingPad = false; });
canvas.addEventListener('mouseleave', () => { GAME.mouse.in = false; }); canvas.addEventListener('mouseleave', () => { GAME.mouse.in = false; });
canvas.addEventListener('contextmenu', e => e.preventDefault()); canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('mousedown', e => { canvas.addEventListener('mousedown', e => {
+18 -8
View File
@@ -12,13 +12,16 @@ function scanVisible() {
} }
let ambientT = 0; let ambientT = 0;
function frame(now) { function frame(now) {
if (GPU.stopped) return;
requestAnimationFrame(frame); requestAnimationFrame(frame);
if (GPU.recovering) { lastT = now; return; }
const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now; const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now;
if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return; if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return;
const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now; const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now;
if (GAME.loading) { acc = 0; return; } if (GAME.loading) { acc = 0; return; }
if (!GAME.running) { pollPad(); return; } if (!GAME.running) { pollPad(); return; }
const t0 = performance.now(); const t0 = performance.now();
let frameSim = 0, frameSteps = 0;
const pad = pollPad(); const pad = pollPad();
if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; } if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; }
else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; } else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; }
@@ -37,7 +40,14 @@ function frame(now) {
GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0; GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0;
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24); botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
toolTick(); toolTick();
const s0 = performance.now(); simTick(); simMs = simMs * 0.9 + (performance.now() - s0) * 0.1; const s0 = performance.now();
try { simTick(); }
catch (e) {
GAME.paused = true; acc = 0; console.error(e);
toast('The world stopped updating. Reload this world to try again.', {big:true}); break;
}
const elapsed = performance.now() - s0;
simMs = simMs * 0.9 + elapsed * 0.1; frameSim += elapsed; frameSteps++;
if (devPaused) devSteps--; else acc -= step; if (devPaused) devSteps--; else acc -= step;
steps++; steps++;
GAME.playTime += step; GAME.playTime += step;
@@ -57,15 +67,11 @@ function frame(now) {
{ const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); } { const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); }
R.frame++; R.frame++;
drawWorld(); drawWorld();
drawCracks(); const overlayStart = SET.showPerf ? performance.now() : 0;
if (!weather.seen && (R.rain || 0) > 0.12) { if (!weather.seen && (R.rain || 0) > 0.12) {
weather.seen = true; weather.seen = true;
showCard({ kind: 'Weather', title: 'Rain', swatch: WATER, body: ['Steam that rises into the open sky gathers into clouds and comes back down as rain nearby. On freezing heights it falls as snow.', 'Water is never used up, only moved around. If some leaves the world (dropped into the dock, say), a shower from beyond the claim makes it up.'] }); showCard({ kind: 'Weather', title: 'Rain', swatch: WATER, body: ['Steam that rises into the open sky gathers into clouds and comes back down as rain nearby. On freezing heights it falls as snow.', 'Water is never used up, only moved around. If some leaves the world (dropped into the dock, say), a shower from beyond the claim makes it up.'] });
} }
if (SET.look === 'smooth' && SM.tried && !SM.ok) {
SET.look = 'pixel'; saveSettings();
toast(SM.why === 'slow' ? 'The smooth look was too slow on this computer, so the pixel look is on. You can try it again in Settings.' : SM.why === 'software' ? 'This browser is drawing without the graphics card, so the smooth look would crawl. The pixel look is on.' : 'This browser cannot draw the smooth look, so the pixel look is on.');
}
drawMachines(); drawMachines();
drawMolds(moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y))); drawMolds(moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)));
drawParticles(); drawParticles();
@@ -74,12 +80,14 @@ function frame(now) {
if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); } if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); }
drawJoinMarks(); drawJoinMarks();
drawPing(); drawPing();
drawHoveredVein();
drawPingRecharge(); drawPingRecharge();
drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb); drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb);
drawFloaters(); drawFloaters();
drawRain(); drawRain();
drawBot(); drawBot();
if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots
if (SET.showPerf) perfTimes.overlays = (perfTimes.overlays || 0) * 0.9 + (performance.now() - overlayStart) * 0.1;
if (GAME.replace !== GAME.replaceShown) { GAME.replaceShown = GAME.replace; hudDirty = true; } if (GAME.replace !== GAME.replaceShown) { GAME.replaceShown = GAME.replace; hudDirty = true; }
renderBuildBar(); renderBuildBar();
if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; } if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; }
@@ -88,11 +96,13 @@ function frame(now) {
updateOverlayUI(); updateOverlayUI();
if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); } if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); }
updateMap(now); updateMap(now);
frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1; const frameWork = performance.now() - t0;
frameMs = frameMs * 0.9 + frameWork * 0.1;
if (PERF_FRAME) PERF_FRAME({ at: now, work: frameWork, sim: frameSim, steps: frameSteps });
fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05; fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05;
if (SET.showPerf && R.frame % 15 === 0) { if (SET.showPerf && R.frame % 15 === 0) {
let aw = 0, ht = 0; for (let c = 0; c < NCH; c++) { if (awake[c]) aw++; if (hot[c]) ht++; } let aw = 0, ht = 0; for (let c = 0; c < NCH; c++) { if (awake[c]) aw++; if (hot[c]) ht++; }
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`; $('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick (move ${(perfTimes.simMove || 0).toFixed(1)}, heat ${(perfTimes.heatPass || 0).toFixed(1)}, rest ${Math.max(0, simMs - (perfTimes.simMove || 0) - (perfTimes.heatPass || 0)).toFixed(1)})\nworld ${(perfTimes.drawWorld || 0).toFixed(1)} ms · overrides ${(perfMarks.override || 0).toFixed(1)} · upload ${(perfMarks.upload || 0).toFixed(1)} · GPU ${GPU.gpuMs === null ? "—" : GPU.gpuMs.toFixed(1)} ms (light ${(GPU.lightMs || 0).toFixed(1)}) · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
} }
} }
+15
View File
@@ -0,0 +1,15 @@
// Browser-only clocks; simulation code stays usable without a browser or a clock.
let PERF_MARKS = false, PERF_FRAME = null;
const perfMarks = {}, perfTimes = {}, perfLast = {};
for (const name of ['drawWorld', 'simMove', 'heatPass']) {
const original = window[name];
window[name] = function (...args) {
if (!SET.showPerf && !PERF_MARKS) return original.apply(this, args);
const start = performance.now();
const result = original.apply(this, args);
const ms = performance.now() - start;
perfLast[name] = ms;
perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1;
return result;
};
}
+1 -41
View File
@@ -1,44 +1,4 @@
// Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. // Drill pulse wavefronts and aiming fan.
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking.
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines
// run on unbroken from one cell to the next and look like real fractures, not a pattern.
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))];
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }];
function crackSeg(ctx, x, y, n, sc, zp, oy) {
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61);
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1);
const pts = [];
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y);
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c));
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1);
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d));
for (let k = 0; k + 3 < pts.length; k += 4) {
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy);
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy);
}
}
function drawCracks() {
const ctx = R.ctx, zp = R.zp;
if (zp < 2.5) return;
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY));
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp));
const cells = [];
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } }
if (!cells.length) return;
ctx.save(); ctx.lineCap = 'round';
for (let l = 0; l < CRACK_LV.length; l++) {
const L = CRACK_LV[l];
// a soft lit lip just below each crack, then the dark crack itself
for (const pass of [0, 1]) {
ctx.beginPath();
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
ctx.stroke();
}
}
ctx.restore();
}
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes // The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
function pulseRGB(tune) { function pulseRGB(tune) {
if (!tune) return '150,215,255'; if (!tune) return '150,215,255';
+9 -5
View File
@@ -10,14 +10,15 @@ function showLoading(on, p, label, title) {
$('loading').hidden = !on; $('loading').hidden = !on;
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; } if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
} }
function enterGame() { function enterGame(renderReady = false) {
closeDevConsole(); $('dev-output').textContent = ''; $('dev-input').value = ''; closeDevConsole(); $('dev-output').textContent = ''; $('dev-input').value = '';
$('cards').innerHTML = ''; $('cards').innerHTML = '';
if (R.floaters) R.floaters.length = 0; R.particles.length = 0; if (R.floaters) R.floaters.length = 0; R.particles.length = 0;
GAME.status = ''; GAME.statusT = 0; GAME.status = ''; GAME.statusT = 0;
$('title').hidden = true; $('hud').hidden = false; $('title').hidden = true; $('hud').hidden = false;
GAME.running = true; GAME.paused = false; GAME.running = true; GAME.paused = false;
buildSky(); resizeRender(); snapCamera(); resetMap(); if (!renderReady) { buildSky(); resizeRender(); snapCamera(); }
resetMap();
GAME.lastAutosave = performance.now(); GAME.lastAutosave = performance.now();
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = ''; GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
hudDirty = true; renderHotbar(); hudDirty = true; renderHotbar();
@@ -32,6 +33,7 @@ async function setupNewWorld(seed, w, h, name, carried, title = 'Generating worl
if (carried) applyCareerState(carried); if (carried) applyCareerState(carried);
} }
async function startNewWorld(seed, w, h, name = '') { async function startNewWorld(seed, w, h, name = '') {
if (!await canStartGame()) return false;
try { try {
const worlds = await listWorlds(); const worlds = await listWorlds();
await setupNewWorld(seed, w, h, cleanWorldName(name, claimName(seed, worlds.map(w => w.name)))); await setupNewWorld(seed, w, h, cleanWorldName(name, claimName(seed, worlds.map(w => w.name))));
@@ -40,11 +42,13 @@ async function startNewWorld(seed, w, h, name = '') {
showLoading(true, 0.9, 'Saving'); showLoading(true, 0.9, 'Saving');
await saveWorld('auto'); await saveWorld('auto');
showLoading(true, 1, 'Saving'); showLoading(true, 1, 'Saving');
showLoading(false); closeAllModals(); enterGame(); await gpuWarmWorld();
showLoading(false); closeAllModals(); enterGame(true);
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 }); showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); } } catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
} }
async function startFromSave(sv, onProgress) { async function startFromSave(sv, onProgress) {
if (!await canStartGame()) return false;
onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world')); onProgress = onProgress || ((f, s) => showLoading(true, f, s, 'Loading world'));
auInit(); auInit();
GAME.paused = true; GAME.paused = true;
@@ -65,8 +69,8 @@ async function startFromSave(sv, onProgress) {
GAME.drag = null; GAME.blow.clear(); GAME.drag = null; GAME.blow.clear();
onProgress(0.95, 'Your gear and notebook'); onProgress(0.95, 'Your gear and notebook');
await loadCareer(); await loadCareer();
showLoading(false); await gpuWarmWorld();
enterGame(); showLoading(false); enterGame(true);
return true; return true;
} }
async function loadSavedWorld(id) { async function loadSavedWorld(id) {
+2 -3
View File
@@ -8,7 +8,7 @@ const SET_TABS = {
{ k: 'uiScale', label: 'Interface size', type: 'range', min: 0.8, max: 1.4, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'uiScale', label: 'Interface size', type: 'range', min: 0.8, max: 1.4, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] }, { k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] },
{ k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen }, { k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen },
{ k: 'cursorLabel', label: 'Name next to the cursor', d: 'Shows what you are pointing at right beside the cursor.', type: 'toggle' }, { k: 'cursorLabel', label: 'Name next to the cursor', d: 'Briefly names ores and metals, or everything. Warnings stay visible.', type: 'select', opts: [['off', 'Off'], ['ores', 'Ores only'], ['all', 'Everything']] },
{ k: 'minimap', label: 'Show minimap', type: 'toggle' }, { k: 'minimap', label: 'Show minimap', type: 'toggle' },
{ k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' }, { k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' },
{ k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' }, { k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
@@ -29,6 +29,7 @@ const SET_TABS = {
{ note: 'Upscaling (DLSS, FSR), frame generation and hardware ray tracing need native graphics APIs that browsers do not expose. They are planned for the compiled build.' }, { note: 'Upscaling (DLSS, FSR), frame generation and hardware ray tracing need native graphics APIs that browsers do not expose. They are planned for the compiled build.' },
], ],
Simulation: [ Simulation: [
{ k: 'workerCap', label: 'CPU workers', d: 'Maximum helpers for grains and heat. Applies when you next load a world.', type: 'range', min: 1, max: 16, step: 1, fmt: v => v },
{ k: 'simSpeed', label: 'Simulation speed', type: 'select', opts: [[0.5, '0.5×'], [1, '1×'], [2, '2×']] }, { k: 'simSpeed', label: 'Simulation speed', type: 'select', opts: [[0.5, '0.5×'], [1, '1×'], [2, '2×']] },
{ k: 'maxSteps', label: 'Catch-up steps per frame', d: 'How hard the game tries to keep real time when frames are slow.', type: 'range', min: 1, max: 4, step: 1, fmt: v => v }, { k: 'maxSteps', label: 'Catch-up steps per frame', d: 'How hard the game tries to keep real time when frames are slow.', type: 'range', min: 1, max: 4, step: 1, fmt: v => v },
{ note: 'The whole world keeps simulating, wherever you are. Your factories keep running when you fly away. Areas where nothing moves or changes temperature go to sleep and cost almost nothing; busy areas cost the same near you or far away.' }, { note: 'The whole world keeps simulating, wherever you are. Your factories keep running when you fly away. Areas where nothing moves or changes temperature go to sleep and cost almost nothing; busy areas cost the same near you or far away.' },
@@ -97,8 +98,6 @@ function renderSettings() {
} }
} }
function applySettings() { function applySettings() {
// choosing Smooth again after it was too slow: try once more, at half resolution
if (SET.look === 'smooth' && SM.tried && !SM.ok && SM.why === 'slow' && SM.gl) { SM.ok = true; SM.why = ''; SM.scale = 0.5; SM.n = 0; }
saveSettings(); saveSettings();
document.documentElement.style.setProperty('--ui', SET.uiScale); document.documentElement.style.setProperty('--ui', SET.uiScale);
if (R.canvas) { resizeRender(); R.snapNext = true; } if (R.canvas) { resizeRender(); R.snapNext = true; }
+3 -2
View File
@@ -5,15 +5,16 @@
const SET_DEFAULT = { const SET_DEFAULT = {
zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, cameraLead: true, zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, cameraLead: true,
look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true, look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true,
minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, workerCap: 16,
volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true, volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true,
deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: true, sv: 2, deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: 'ores', sv: 2,
}; };
const SET = Object.assign({}, SET_DEFAULT); const SET = Object.assign({}, SET_DEFAULT);
// sv: settings version. 2: the default zoom became fully zoomed in, so older saved settings move there once. // sv: settings version. 2: the default zoom became fully zoomed in, so older saved settings move there once.
function loadSettings() { function loadSettings() {
try { try {
const s = JSON.parse(localStorage.getItem('deepgrain.settings') || '{}'); Object.assign(SET, s); const s = JSON.parse(localStorage.getItem('deepgrain.settings') || '{}'); Object.assign(SET, s);
if (typeof SET.cursorLabel === 'boolean') SET.cursorLabel = SET.cursorLabel ? 'ores' : 'off';
if (!(s.sv >= 2)) { SET.zoom = SET_DEFAULT.zoom; SET.sv = 2; if (Object.keys(s).length) saveSettings(); } if (!(s.sv >= 2)) { SET.zoom = SET_DEFAULT.zoom; SET.sv = 2; if (Object.keys(s).length) saveSettings(); }
} catch (e) { /* storage unavailable */ } } catch (e) { /* storage unavailable */ }
} }
+39
View File
@@ -0,0 +1,39 @@
// Browser transport only; the simulation owns the schedule and shared state.
let threadStarting = null;
async function startThreads() {
const count = Math.max(1, Math.min(16, Math.floor(SET.workerCap) || 16, (navigator.hardwareConcurrency || 3) - 2));
if (simPool && simPool.world === mat && simPool.workers.length === count) return;
if (threadStarting) await threadStarting;
if (simPool && simPool.world === mat && simPool.workers.length === count) return;
threadStarting = (async () => {
if (simPool) for (const worker of simPool.workers) worker.terminate();
simPool = null;
if (!(mat.buffer instanceof SharedArrayBuffer)) throw Error('Shared memory is unavailable');
const url = URL.createObjectURL(new Blob([SIM_WORKER_SOURCE, '\nonmessage=e=>threadMessage(e.data);'], {type:'text/javascript'}));
const pool = { world: mat, workers: [], slots: [] };
try {
const ready = [];
for (let k = 0; k < count; k++) {
const worker = new Worker(url), slot = threadSlot();
pool.workers.push(worker); pool.slots.push(slot);
ready.push(new Promise((resolve,reject) => {
const deadline = performance.now() + 10000;
worker.onerror = e => {
const error = Error(e.message || 'Simulation worker failed');
threadFailure(slot, error); reject(error);
};
const poll = () => {
if (Atomics.load(slot.control,0)) resolve();
else if (performance.now() > deadline) reject(Error('Simulation worker startup timed out'));
else setTimeout(poll, 1);
};
poll();
}));
worker.postMessage({world:threadWorld(),slot});
}
await Promise.all(ready); simPool = pool;
} catch (e) { for (const worker of pool.workers) worker.terminate(); throw e; }
finally { URL.revokeObjectURL(url); }
})();
try { await threadStarting; } finally { threadStarting = null; }
}
+32 -1
View File
@@ -1,4 +1,35 @@
// The title screen art. // Startup capabilities and title art.
const REQUIRE_WEBGPU = true;
const REQUIRE_THREADS = true;
const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false };
async function checkCapabilities() {
try {
const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
CAPS.webgpu = !!adapter && gpuLimitsOK(adapter) && await gpuInit(adapter);
CAPS.requiredLimits = GPU_LIMITS;
const info = adapter && adapter.info;
if (info) CAPS.adapterInfo = { vendor: info.vendor, architecture: info.architecture, device: info.device, description: info.description };
} catch (e) { CAPS.webgpu = false; }
CAPS.ready = true;
renderCapabilityNotice();
}
function renderCapabilityNotice() {
const messages = [];
if (!CAPS.isolated) messages.push("Deepgrain needs to be served (for example with python3 tools/serve.py) so it can use all CPU cores; opened from a file it can't.");
if (!CAPS.webgpu) messages.push(/Firefox\//.test(navigator.userAgent)
? 'WebGPU is unavailable. In Firefox, open about:config and set dom.webgpu.enabled to true, then restart. On Linux, use Firefox Nightly. If no graphics adapter is available, try Chrome or Edge.'
: 'WebGPU is unavailable. Use a browser with WebGPU (Chrome or Edge).');
if (GPU.error) messages.push('WebGPU could not start: ' + GPU.error);
const notice = $('cap-notice');
notice.replaceChildren(...messages.map(text => { const p = document.createElement('p'); p.textContent = text; return p; }));
notice.hidden = !messages.length;
}
async function canStartGame() {
await CAPS_CHECK;
return (!REQUIRE_WEBGPU || (CAPS.webgpu && GPU.ok)) && (!REQUIRE_THREADS || CAPS.isolated);
}
const CAPS_CHECK = checkCapabilities();
// ---------- Title art ---------- // ---------- Title art ----------
const FONT5 = { const FONT5 = {
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
+39
View File
@@ -0,0 +1,39 @@
"""An isolated Firefox title screen and three seconds of a new pixel world."""
import json
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright
ensure_playwright()
from browser import capabilities, consume_gpu_option, installed, launch
consume_gpu_option()
if not installed('firefox'):
print('SKIP firefox_smoke.py: Playwright Firefox is not installed')
sys.exit(0)
with launch(browser_name='firefox') as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
page.add_init_script("localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));")
page.goto(url)
caps = capabilities(page)
print('Firefox: ' + json.dumps(caps))
assert caps['isolated']
if not caps['webgpu']:
assert page.locator('#cap-notice').is_visible()
text = page.inner_text('#cap-notice')
assert 'about:config' in text and 'dom.webgpu.enabled' in text and 'Firefox Nightly' in text
print('PASS Firefox isolation and capability notice')
if not caps['webgpu']:
assert not page.evaluate('canStartGame()')
assert not page.evaluate('startNewWorld(424242,512,384)')
assert not page.evaluate('GAME.running')
print('PASS Firefox without WebGPU cannot start a world')
else:
page.evaluate('startNewWorld(424242,512,384)')
page.wait_for_function('GAME.running && !GAME.loading',timeout=60000)
before=page.evaluate('tick');page.wait_for_timeout(3000)
assert page.evaluate('tick')>before
assert not errors,errors
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+5 -3
View File
@@ -1,4 +1,4 @@
// Reference heat loop before t21 phase 3; preserves conduction and Float32 store order. // Unoptimized heat oracle, re-baselined for t88 chunk-pass heat outputs.
function referenceHeatChunk(c, sc) { function referenceHeatChunk(c, sc) {
const cx = c % CW, cy = (c / CW) | 0; const cx = c % CW, cy = (c / CW) | 0;
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32); const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
@@ -37,6 +37,8 @@ function referenceHeatChunk(c, sc) {
if (flags[i] & F_BURN) burning = true; if (flags[i] & F_BURN) burning = true;
} }
} }
if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; } const h = burning || maxDev > 1.5 ? 6 : hot[c] - 1;
else hot[c]--; const level = burning || maxDev > 1.5 ? ((burning || maxDev > 60) ? 2 : 1) : hotLvl[c];
if (chunkWork) { chunkWork.ownHot[c] = h; chunkWork.ownLevel[c] = level; }
else { hot[c] = h; hotLvl[c] = level; }
} }
+30
View File
@@ -0,0 +1,30 @@
// Read the four actual smooth textures without adding COPY_SRC usage to game resources.
async function testSmoothInputs() {
const d=GPU.device, code=`
@group(0) @binding(0) var col: texture_2d<f32>;
@group(0) @binding(1) var back: texture_2d<f32>;
@group(0) @binding(2) var tree: texture_2d<f32>;
@group(0) @binding(3) var field: texture_2d<f32>;
@group(0) @binding(4) var<storage,read_write> out: array<u32>;
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3u) {
let size=textureDimensions(col); let k=id.x; if(k>=size.x*size.y){return;}
let p=vec2i(i32(k%size.x),i32(k/size.x));
out[k*4]=pack4x8unorm(textureLoad(col,p,0));
out[k*4+1]=pack4x8unorm(textureLoad(back,p,0));
out[k*4+2]=pack4x8unorm(textureLoad(tree,p,0));
out[k*4+3]=pack4x8unorm(textureLoad(field,p,0));
}`;
const pipe=d.createComputePipeline({layout:'auto',compute:{module:d.createShaderModule({code}),entryPoint:'main'}});
const bytes=R.px.length*16;
const storage=d.createBuffer({size:bytes,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC});
const read=d.createBuffer({size:bytes,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
const entries=[GPU.colourTex,GPU.backTex,GPU.treeTex,GPU.fieldTex].map((t,binding)=>({binding,resource:t.createView()}));
entries.push({binding:4,resource:{buffer:storage}});
const bind=d.createBindGroup({layout:pipe.getBindGroupLayout(0),entries});
const enc=d.createCommandEncoder(),pass=enc.beginComputePass();pass.setPipeline(pipe);pass.setBindGroup(0,bind);pass.dispatchWorkgroups(Math.ceil(R.px.length/64));pass.end();
enc.copyBufferToBuffer(storage,0,read,0,bytes);d.queue.submit([enc.finish()]);
await read.mapAsync(GPUMapMode.READ);
const actual=new Uint32Array(read.getMappedRange()).slice();
read.unmap();storage.destroy();read.destroy();
return actual;
}
+31
View File
@@ -81,3 +81,34 @@ function dump(run, x0, y0, x1, y1, withTemp) {
} }
} }
module.exports.dump = dump; module.exports.dump = dump;
// Node uses exactly the browser worker entry point and scheduler.
async function enableThreads(run, count) {
const { Worker } = require('worker_threads');
const entry = `const {parentPort}=require('worker_threads');
require(${JSON.stringify(__filename)}).load();
parentPort.on('message', data=>threadMessage(data));`;
const pool = { world: run('mat'), workers: [], slots: [], force: true };
try {
const ready = [];
for (let k = 0; k < count; k++) {
const worker = new Worker(entry, {eval:true}), slot = run('threadSlot()');
pool.workers.push(worker); pool.slots.push(slot);
ready.push(new Promise((resolve,reject) => {
const deadline = Date.now()+10000;
worker.once('error',reject);
const poll = () => {
if (Atomics.load(slot.control,0)) resolve();
else if (Date.now()>deadline) reject(Error('Worker startup timed out'));
else setTimeout(poll,1);
};
poll();
}));
worker.postMessage({world:run('threadWorld()'),slot});
}
await Promise.all(ready);
globalThis.__testPool = pool; run('simPool = globalThis.__testPool;'); delete globalThis.__testPool;
return async () => { run('simPool=null;'); await Promise.all(pool.workers.map(w=>w.terminate())); };
} catch (e) { await Promise.all(pool.workers.map(w=>w.terminate())); throw e; }
}
module.exports.enableThreads = enableThreads;
+46 -57
View File
@@ -1,19 +1,6 @@
// Run the renderer's lighting against real simulation data, with only canvas storage stubbed. // Lighting behaviour checks executed by the GPU browser harness.
const fs = require('fs'); await flatWorld(run, 320, 200, 160);
const path = require('path'); await run(`
const { load, suite, flatWorld } = require('./harness');
const { run } = load();
const { check, done } = suite();
for (const f of ['setup.js', 'light.js']) run(fs.readFileSync(path.join(__dirname, '../src/render', f), 'utf8'));
run(`
const SET = { lighting: 'full', daylight: 1, ambient: 0.12 };
R.lightC = {};
R.lightX = { createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), putImageData() {} };
R.vx0 = 0; R.vy0 = 100; R.bw = 320; R.bh = 80;
function lightAt(a, x, y) { return a[(Math.floor(y / R.lres) - R.ly0) * R.lw + Math.floor(x / R.lres) - R.lx0]; }
`);
flatWorld(run, 320, 200, 160);
run(`
bot.lamp = false; bot.lamp = false;
for (let bx = 8; bx < 30; bx++) placeBlock(BP_WALL, RSTONE, bx, 20, true); for (let bx = 8; bx < 30; bx++) placeBlock(BP_WALL, RSTONE, bx, 20, true);
for (let by = 21; by < 39; by++) { for (let by = 21; by < 39; by++) {
@@ -26,43 +13,43 @@ run(`
addMold('bit', 24, 30, RSTONE, true); addMold('bit', 24, 30, RSTONE, true);
for (let y = 80; y < 88; y++) for (let x = 180; x < 280; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); for (let y = 80; y < 88; y++) for (let x = 180; x < 280; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);
updateRoof(); updateRoof();
const simRoof = Array.from(roofY); window.simRoof = Array.from(roofY);
`); `);
for (const mode of ['full', 'fast']) { for (const mode of ['full', 'fast']) {
run(`SET.lighting = '${mode}'; computeLight();`); await run(`SET.lighting = '${mode}'; computeLight();`);
for (const channel of ['lr', 'lg']) { for (const channel of ['lr', 'lg']) {
const [inside, open, rock, ground, openGround] = run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`); const [inside, open, rock, ground, openGround] = await run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`);
check(`${mode} ${channel}: under a built roof matches open daylight`, Math.abs(inside / open - 1) < 0.03, `${inside} / ${open}`); check(`${mode} ${channel}: under a built roof matches open daylight`, Math.abs(inside / open - 1) < 0.03, `${inside} / ${open}`);
check(`${mode} ${channel}: ground under the base matches outside`, Math.abs(ground / openGround - 1) < 0.03, `${ground} / ${openGround}`); check(`${mode} ${channel}: ground under the base matches outside`, Math.abs(ground / openGround - 1) < 0.03, `${ground} / ${openGround}`);
check(`${mode} ${channel}: natural overhang still shades`, rock < open * 0.95, `${rock} / ${open}`); check(`${mode} ${channel}: natural overhang still shades`, rock < open * 0.95, `${rock} / ${open}`);
} }
// Directly check transmission as daylight can hide an opaque piece in the open. // Directly check transmission as daylight can hide an opaque piece in the open.
for (const [name, x, y] of [['wall', 32, 120], ['belt', 48, 120], ['screw', 56, 120], ['machine', 72, 120], ['mold', 96, 120]]) { for (const [name, x, y] of [['wall', 32, 120], ['belt', 48, 120], ['screw', 56, 120], ['machine', 72, 120], ['mold', 96, 120]]) {
const a = run(`lightAt(R.latt, ${x}, ${y})`); const a = await run(`lightAt(R.latt, ${x}, ${y})`);
check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a); check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-3, a);
} }
} }
check('lighting leaves the simulation roof unchanged', run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80')); check('lighting leaves the simulation roof unchanged', await run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80'));
for (const material of ['STONE', 'SAND', 'WATER']) { for (const material of ['STONE', 'SAND', 'WATER']) {
run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); setCell(97 * W + 80, STONE, AMB[97], F_NAT); updateRoof(); computeLight();`); await run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); setCell(97 * W + 80, STONE, AMB[97], F_NAT); updateRoof(); computeLight();`);
check(`${material} above the view still blocks daylight`, run('lightAt(R.lr, 80, 144) < 0.95')); check(`${material} above the view still blocks daylight`, await run('lightAt(R.lr, 80, 144) < 0.925'));
run('setCell(96 * W + 80, EMPTY, AMB[96], 0); setCell(97 * W + 80, EMPTY, AMB[97], 0);'); await run('setCell(96 * W + 80, EMPTY, AMB[96], 0); setCell(97 * W + 80, EMPTY, AMB[97], 0);');
} }
run('updateRoof(); computeLight();'); await run('updateRoof(); computeLight();');
check('removing an obstruction restores daylight', run('lightAt(R.lr, 80, 144) === 1')); check('removing an obstruction restores daylight', await run('lightAt(R.lr, 80, 144) === 1'));
for (const field of ['blockP[24 * BW + 20]', 'mach[96 * W + 80]', 'part[96 * W + 80]']) { for (const field of ['blockP[24 * BW + 20]', 'mach[96 * W + 80]', 'part[96 * W + 80]']) {
run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`); await run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`);
check(`${field}: built contents do not shade or absorb light`, run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-6')); check(`${field}: built contents do not shade or absorb light`, await run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-3'));
run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`); await run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`);
} }
run('updateRoof();'); await run('updateRoof();');
run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`); await run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`);
check('hot material inside the base lights its surroundings', run('lightAt(R.lr, 88, 140) > 0.2')); check('hot material inside the base lights its surroundings', await run('lightAt(R.lr, 88, 140) > 0.2'));
run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`); await run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`);
check('the bot lamp still lights the base', run('lightAt(R.lr, 80, 140) > 0.2')); check('the bot lamp still lights the base', await run('lightAt(R.lr, 80, 140) > 0.2'));
flatWorld(run, 320, 200, 160); await flatWorld(run, 320, 200, 160);
run(` await run(`
SET.daylight = 1; bot.lamp = false; SET.daylight = 1; bot.lamp = false;
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 20, true); for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 20, true);
for (let x = 32; x < 120; x++) for (let y = 77; y < 80; y++) setCell(y * W + x, SAND, AMB[y], 0); for (let x = 32; x < 120; x++) for (let y = 77; y < 80; y++) setCell(y * W + x, SAND, AMB[y], 0);
@@ -74,19 +61,20 @@ run(`
updateRoof(); updateRoof();
`); `);
for (const mode of ['full', 'fast']) { for (const mode of ['full', 'fast']) {
run(`SET.lighting = '${mode}'; computeLight();`); await run(`SET.lighting = '${mode}'; computeLight();`);
for (const channel of ['lr', 'lg', 'lb']) { for (const channel of ['lr', 'lg', 'lb']) {
const [belt, open, stone] = run(`[lightAt(R.${channel}, 80, 84), lightAt(R.${channel}, 160, 84), lightAt(R.${channel}, 232, 84)]`); const [belt, open, stone] = await run(`[lightAt(R.${channel}, 80, 84), lightAt(R.${channel}, 160, 84), lightAt(R.${channel}, 232, 84)]`);
check(`${mode} ${channel}: a loaded belt leaves daylight below it`, Math.abs(belt - open) < 1e-6, `${belt} / ${open}`); check(`${mode} ${channel}: a loaded belt leaves daylight below it`, Math.abs(belt - open) < 1e-3, `${belt} / ${open}`);
check(`${mode} ${channel}: sand on natural stone still shades below`, stone < open * 0.95, `${stone} / ${open}`); check(`${mode} ${channel}: sand on natural stone still shades below`, stone < open * 0.95, `${stone} / ${open}`);
const [streamGround, openGround] = run(`[lightAt(R.${channel}, 152, 160), lightAt(R.${channel}, 160, 160)]`); const [streamGround, openGround] = await run(`[lightAt(R.${channel}, 152, 160), lightAt(R.${channel}, 160, 160)]`);
check(`${mode} ${channel}: a falling stream leaves ground as bright as open sky`, Math.abs(streamGround - openGround) < 1e-6, `${streamGround} / ${openGround}`); check(`${mode} ${channel}: a falling stream leaves ground as bright as open sky`, Math.abs(streamGround - openGround) < 1e-3, `${streamGround} / ${openGround}`);
} }
run('setCell(76 * W + 80, WATER, AMB[76], 0); updateRoof(); computeLight();'); await run('setCell(76 * W + 80, WATER, AMB[76], 0); updateRoof(); computeLight();');
const a = run('lightAt(R.latt, 80, 76)'); const a = await run('lightAt(R.latt, 80, 76)');
check(`${mode}: a wet belt load transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a); check(`${mode}: a wet belt load transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-3, a);
run('setCell(76 * W + 80, EMPTY, AMB[76], 0); updateRoof();'); await run('setCell(76 * W + 80, EMPTY, AMB[76], 0); updateRoof();');
} }
await run('R.camY=0;');
for (const [name, support, depth, gap, expected] of [ for (const [name, support, depth, gap, expected] of [
['left belt', 'mat[i] = BELTL', 12, '', true], ['left belt', 'mat[i] = BELTL', 12, '', true],
['right belt', 'mat[i] = BELTR', 12, '', true], ['right belt', 'mat[i] = BELTR', 12, '', true],
@@ -102,28 +90,29 @@ for (const [name, support, depth, gap, expected] of [
['screw', 'part[i] = P_SCREW_R', 3, '', true], ['screw', 'part[i] = P_SCREW_R', 3, '', true],
['loaded screw', 'mat[i] = SAND; part[i] = P_SCREW_R', 3, '', true], ['loaded screw', 'mat[i] = SAND; part[i] = P_SCREW_R', 3, '', true],
]) { ]) {
run(` await run(`
for (let y = 40; y <= 53; y++) { const i = y * W + 300; mat[i] = SAND; part[i] = 0; flags[i] = 0; } for (let y = 40; y <= 53; y++) { const i = y * W + 300; mat[i] = SAND; part[i] = 0; flags[i] = 0; }
{ const i = (40 + ${depth}) * W + 300; mat[i] = EMPTY; mat[i + W] = BELTR; ${support}; } { const i = (40 + ${depth}) * W + 300; mat[i] = EMPTY; mat[i + W] = BELTR; ${support}; }
${gap ? `mat[41 * W + 300] = ${gap};` : ''} ${gap ? `mat[41 * W + 300] = ${gap};` : ''}
`); `);
check(`${name}: loose cell detection`, run('looseForLight(40 * W + 300)') === expected); check(`${name}: loose cell detection`, await run('lightAt(R.latt,300,40) > 0.9') === expected);
} }
run('mat[40 * W + 300] = SAND; mat[41 * W + 300] = EMPTY;'); await run('mat[40 * W + 300] = SAND; mat[41 * W + 300] = EMPTY;');
check('a single grain in mid-air is see-through', run('looseForLight(40 * W + 300)')); check('a single grain in mid-air is see-through', await run('lightAt(R.latt,300,40) > 0.9'));
run('mat[(H - 1) * W + 300] = SAND;'); await run('R.camY=120;mat[(H - 1) * W + 300] = SAND;');
check('loose cell detection stops at the world bottom', run('!looseForLight((H - 1) * W + 300)')); check('loose cell detection stops at the world bottom', await run('lightAt(R.latt,300,H-1) < 0.92'));
flatWorld(run, 320, 200, 160); await flatWorld(run, 320, 200, 160);
run(` await run('R.camY=100;');
await run(`
bot.lamp = false; stats.refined = 0; bot.lamp = false; stats.refined = 0;
placeBlock(BP_GRIND, 0, 20, 20, true); placeBlock(BP_GRIND, 0, 20, 20, true);
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 36, true); for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 36, true);
setCell(79 * W + 80, SAND, AMB[79], 0); setCell(79 * W + 80, SAND, AMB[79], 0);
`); `);
check('sand resting on grinder housing is see-through', run('looseForLight(79 * W + 80)')); check('sand resting on grinder housing is see-through', await run('lightAt(R.latt,80,79) > 0.9'));
for (const mode of ['full', 'fast']) { for (const mode of ['full', 'fast']) {
const result = run(`(() => { const result = await run(`(() => {
SET.lighting = '${mode}'; SET.lighting = '${mode}';
let mismatches = 0, first = ''; let mismatches = 0, first = '';
for (let t = 0; t < 180; t++) { for (let t = 0; t < 180; t++) {
@@ -142,5 +131,5 @@ for (const mode of ['full', 'fast']) {
})()`); })()`);
check(`${mode}: streaming grinder leaves ground in daylight for 180 consecutive ticks`, result.mismatches === 0, result.first || 'no shadow'); check(`${mode}: streaming grinder leaves ground in daylight for 180 consecutive ticks`, result.mismatches === 0, result.first || 'no shadow');
} }
check('the streaming grinder actually processes its input', run('stats.refined > 0'), run('stats.refined')); check('the streaming grinder actually processes its input', await run('stats.refined > 0'), await run('stats.refined'));
done(); done();
+22
View File
@@ -0,0 +1,22 @@
// Read float source channels, including transmittance, from the WGSL light pass.
function lightAt(a,x,y) { return a[(Math.floor(y/R.lres)-R.ly0)*R.lw+Math.floor(x/R.lres)-R.lx0]; }
async function testLight() {
if (!W) return;
renderDirty.fill(1);R.frame++;drawWorld();
const d=GPU.device;
if(!window.testLightPipe) window.testLightPipe=await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:`
@group(0) @binding(0) var source:texture_2d<f32>;
@group(0) @binding(1) var<storage,read_write> out:array<vec4f>;
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id:vec3u){
let size=textureDimensions(source);let k=id.x;if(k>=size.x*size.y){return;}
out[k]=textureLoad(source,vec2i(i32(k%size.x),i32(k/size.x)),0);
}`}),entryPoint:'main'}});
const bytes=R.lw*R.lh*16, storage=d.createBuffer({size:bytes,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC});
const read=d.createBuffer({size:bytes,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
const bind=d.createBindGroup({layout:testLightPipe.getBindGroupLayout(0),entries:[{binding:0,resource:GPU.lightWork[0].createView()},{binding:1,resource:{buffer:storage}}]});
const enc=d.createCommandEncoder(),pass=enc.beginComputePass();pass.setPipeline(testLightPipe);pass.setBindGroup(0,bind);pass.dispatchWorkgroups(Math.ceil(R.lw*R.lh/64));pass.end();
enc.copyBufferToBuffer(storage,0,read,0,bytes);d.queue.submit([enc.finish()]);await read.mapAsync(GPUMapMode.READ);
const data=new Float32Array(read.getMappedRange());
[R.lr,R.lg,R.lb,R.latt]=[0,1,2,3].map(c=>Float32Array.from({length:R.lw*R.lh},(_,i)=>data[i*4+c]));
read.unmap();read.destroy();storage.destroy();
}
+38 -22
View File
@@ -4,20 +4,19 @@
import json, sys, pathlib, re import json, sys, pathlib, re
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools')) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright() from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright from browser import launch, consume_gpu_option
consume_gpu_option()
root = pathlib.Path(__file__).resolve().parent.parent root = pathlib.Path(__file__).resolve().parent.parent
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'play'); out.mkdir(parents=True, exist_ok=True) out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'play'); out.mkdir(parents=True, exist_ok=True)
url = (root / 'dist' / 'index.html').as_uri()
fails = [] fails = []
def check(name, cond, extra=''): def check(name, cond, extra=''):
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else '')) print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
if not cond: fails.append(name) if not cond: fails.append(name)
with sync_playwright() as p: with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
b = p.chromium.launch() ctx = pg.context
ctx = b.new_context(viewport={'width': 1280, 'height': 800})
pg = ctx.new_page()
logs = [] logs = []
pg.add_init_script("if (!localStorage.getItem('deepgrain.settings')) localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));")
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None) pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None)
pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300) pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300)
@@ -61,13 +60,13 @@ with sync_playwright() as p:
check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId')) check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId'))
pg.evaluate("""() => { pg.evaluate("""() => {
window.__pack = packBuffers; window.__pack = packBuffers;
packBuffers = async b => { await new Promise(r => setTimeout(r, 600)); return __pack(b); }; packBuffers = async b => { await new Promise(r => { window.__finishSave = r; }); return __pack(b); };
window.__saving = saveWorld('main', true); window.__saving = saveWorld('main', true);
}""") }""")
pg.locator('#saving').wait_for(state='visible') pg.locator('#saving').wait_for(state='visible')
pg.screenshot(path=str(out / 'saving-progress.png')) pg.screenshot(path=str(out / 'saving-progress.png'))
check('slow save shows the saving chip', pg.is_visible('#saving')) check('slow save shows the saving chip', pg.is_visible('#saving'))
pg.evaluate('async () => { await __saving; packBuffers = __pack; }') pg.evaluate('async () => { __finishSave(); await __saving; packBuffers = __pack; }')
check('saving chip hides when the save ends', not pg.is_visible('#saving')) check('saving chip hides when the save ends', not pg.is_visible('#saving'))
pg.evaluate('quitToTitle()') pg.evaluate('quitToTitle()')
names = {} names = {}
@@ -174,7 +173,7 @@ with sync_playwright() as p:
check('but not on the title screen', not ev(f'(() => {{ GAME.running = false; const r = {leave}; GAME.running = true; return r; }})()')) check('but not on the title screen', not ev(f'(() => {{ GAME.running = false; const r = {leave}; GAME.running = true; return r; }})()'))
ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4 ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4
ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200) ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200)
check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, SM.ok]')) check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, GPU.ok]'))
# Maps use the real frame loop and keyboard, including settings persistence. # Maps use the real frame loop and keyboard, including settings persistence.
pg.wait_for_function('MAP.image && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)') pg.wait_for_function('MAP.image && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
check('minimap lives in bottom right with its controls', ev("!!document.querySelector('#hud .br #minimap canvas') && ['mini-plus', 'mini-minus', 'mini-map'].every(id => $(id).tagName === 'BUTTON')")) check('minimap lives in bottom right with its controls', ev("!!document.querySelector('#hud .br #minimap canvas') && ['mini-plus', 'mini-minus', 'mini-map'].every(id => $(id).tagName === 'BUTTON')"))
@@ -248,7 +247,8 @@ with sync_playwright() as p:
led = ev(camera_offset) led = ev(camera_offset)
check('camera leads toward the pointer when enabled', all(b - a > 2 for a, b in zip(centred, led)), led) check('camera leads toward the pointer when enabled', all(b - a > 2 for a, b in zip(centred, led)), led)
pg.mouse.move(*mouse) pg.mouse.move(*mouse)
ev(f'GAME.paused = {json.dumps(paused)}; saveSettings()') # Keep world-to-screen targets fixed after checking both camera modes.
ev(f'GAME.paused = {json.dumps(paused)}; SET.cameraLead = false; saveSettings(); snapCamera()')
def cell_to_screen(x, y): def cell_to_screen(x, y):
return ev(f'(() => {{ const dpr = R.canvas.width / innerWidth; const s = worldToScreenPx({x}+0.5, {y}+0.5); return [s.x/dpr, s.y/dpr]; }})()') return ev(f'(() => {{ const dpr = R.canvas.width / innerWidth; const s = worldToScreenPx({x}+0.5, {y}+0.5); return [s.x/dpr, s.y/dpr]; }})()')
def blk(bx, by): return cell_to_screen(bx * 4 + 2, by * 4 + 2) def blk(bx, by): return cell_to_screen(bx * 4 + 2, by * 4 + 2)
@@ -281,7 +281,11 @@ with sync_playwright() as p:
# 1. drill: rock breaks loose, nothing enters the bag # 1. drill: rock breaks loose, nothing enters the bag
pg.mouse.move(*cell_to_screen(bx + 6, gy + 3)); settle(900) pg.mouse.move(*cell_to_screen(bx + 6, gy + 3)); settle(900)
sx, sy = cell_to_screen(bx + 6, gy + 3) sx, sy = cell_to_screen(bx + 6, gy + 3)
hold(sx, sy, 3500) pg.mouse.move(sx, sy); pg.mouse.down()
try:
pg.wait_for_function('stats.dug > 20', timeout=15000)
finally:
pg.mouse.up()
settle(700) settle(700)
check('drill digs with the mouse', ev('stats.dug') > 20, ev('stats.dug')) check('drill digs with the mouse', ev('stats.dug') > 20, ev('stats.dug'))
check('drill does not fill the bag', ev('bagTotal') == 0, ev('bagTotal')) check('drill does not fill the bag', ev('bagTotal') == 0, ev('bagTotal'))
@@ -307,7 +311,8 @@ with sync_playwright() as p:
ev("SET.cardSeconds = 0; $('cards').replaceChildren()") # centred cards cover the mouse work area; discovery was checked above ev("SET.cardSeconds = 0; $('cards').replaceChildren()") # centred cards cover the mouse work area; discovery was checked above
# 2. vacuum, left button: lock onto what you click # 2. vacuum, left button: lock onto what you click
pg.keyboard.press('2'); settle(100) pg.keyboard.press('2')
pg.wait_for_function("GAME.tool === 1 && $('selcard').querySelector('.name')?.textContent === 'Pneumatic nozzle'")
check('Pneumatic nozzle card title and width match the selected tool', ev("GAME.tool === 1 && $('selcard').querySelector('.name').textContent === 'Pneumatic nozzle' && $('selcard').firstElementChild.querySelector('.chip').textContent === `width ${GAME.brush[1]}` && !$('selcard').querySelector('.sub .chip')")) check('Pneumatic nozzle card title and width match the selected tool', ev("GAME.tool === 1 && $('selcard').querySelector('.name').textContent === 'Pneumatic nozzle' && $('selcard').firstElementChild.querySelector('.chip').textContent === `width ${GAME.brush[1]}` && !$('selcard').querySelector('.sub .chip')"))
check('Pneumatic nozzle name appears in the tool list and hotbar title', ev("TOOLS[1].name === 'Pneumatic nozzle' && $('hotbar').children[1].title.startsWith('Pneumatic nozzle')")) check('Pneumatic nozzle name appears in the tool list and hotbar title', ev("TOOLS[1].name === 'Pneumatic nozzle' && $('hotbar').children[1].title.startsWith('Pneumatic nozzle')"))
tgt = ev(f'''(() => {{ for (let r = 0; r < 30; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{ tgt = ev(f'''(() => {{ for (let r = 0; r < 30; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{
@@ -492,6 +497,7 @@ with sync_playwright() as p:
check('Shift lights every loose bag row', ev("blowAllList().every(m => [...$('baglist').querySelectorAll('.bagrow.on .nm')].some(e => e.textContent === MD[m].name))")) check('Shift lights every loose bag row', ev("blowAllList().every(m => [...$('baglist').querySelectorAll('.bagrow.on .nm')].some(e => e.textContent === MD[m].name))"))
pg.screenshot(path=str(out / '03b-blow-all-preview.png')) pg.screenshot(path=str(out / '03b-blow-all-preview.png'))
pg.keyboard.up('Shift'); settle(150) pg.keyboard.up('Shift'); settle(150)
pg.wait_for_function("!$('selcard').querySelector('.vacchoice').textContent.includes('Everything') && document.querySelectorAll('#baglist .bagrow.on').length === 1")
check('releasing Shift restores the chosen blow material', ev("$('selcard').querySelector('.vacchoice').textContent.includes(MD[COAL].name) && !$('selcard').querySelector('.vacchoice').textContent.includes('Everything')")) check('releasing Shift restores the chosen blow material', ev("$('selcard').querySelector('.vacchoice').textContent.includes(MD[COAL].name) && !$('selcard').querySelector('.vacchoice').textContent.includes('Everything')"))
check('releasing Shift lights only coal', ev("(() => { const rows = [...$('baglist').querySelectorAll('.bagrow.on')]; return rows.length === 1 && rows[0].querySelector('.nm').textContent === MD[COAL].name; })()")) check('releasing Shift lights only coal', ev("(() => { const rows = [...$('baglist').querySelectorAll('.bagrow.on')]; return rows.length === 1 && rows[0].querySelector('.nm').textContent === MD[COAL].name; })()"))
bot_at = ev('[bot.x, bot.y]') bot_at = ev('[bot.x, bot.y]')
@@ -1156,6 +1162,12 @@ with sync_playwright() as p:
navigator.getGamepads = () => [window.__pad]; navigator.getGamepads = () => [window.__pad];
}''') }''')
pg.keyboard.press('1'); settle(100) pg.keyboard.press('1'); settle(100)
# The preceding play-through can leave only air below the bot.
ev('''() => {
GAME.drillTune = 0;
const x0 = Math.floor(bot.x + bot.w / 2), y0 = Math.ceil(bot.y + bot.h) + 2;
for (let y = y0; y < y0 + 8; y++) for (let x = x0 - 4; x <= x0 + 4; x++) setCell(y * W + x, DIRT, 20, F_NAT);
}''')
d0 = ev('stats.dug') d0 = ev('stats.dug')
ev("__pad.axes[3] = 0.9; __pad.buttons[7].pressed = true; __pad.buttons[7].value = 1;") ev("__pad.axes[3] = 0.9; __pad.buttons[7].pressed = true; __pad.buttons[7].value = 1;")
settle(1500) settle(1500)
@@ -1206,16 +1218,15 @@ with sync_playwright() as p:
pg.keyboard.press('Escape'); settle(150) pg.keyboard.press('Escape'); settle(150)
# Fog has its own paused scene; existing play checks keep the whole world visible. # Fog has its own paused scene; existing play checks keep the whole world visible.
fb = p.chromium.launch(args=['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']) fog_context = b.new_context(viewport={'width': 1280, 'height': 800})
fp = fb.new_page(viewport={'width': 1280, 'height': 800}) fp = fog_context.new_page()
fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}')) fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}'))
fp.add_init_script('window.__allowSoftwareGL = true')
fp.goto(url) fp.goto(url)
fp.evaluate('startNewWorld(4242, 512, 384)') fp.evaluate('startNewWorld(4242, 512, 384)')
fp.wait_for_function('GAME.running') fp.wait_for_function('GAME.running')
# Silo feedback uses real pointer positions; drawing checks sample an isolated canvas. # Silo feedback uses real pointer positions; drawing checks sample an isolated canvas.
fp.evaluate("""() => { fp.evaluate("""() => {
GAME.paused = true; SET.fog = false; SET.look = 'pixel'; SET.zoom = 8; SET.cursorLabel = true; GAME.paused = true; SET.fog = false; SET.look = 'pixel'; SET.zoom = 8; SET.cursorLabel = 'all';
SET.cameraLead = false; SET.cameraSmooth = 0; resizeRender(); SET.cameraLead = false; SET.cameraSmooth = 0; resizeRender();
for (let y = 80; y < 140; y++) for (let x = 80; x < 160; x++) { setCell(y * W + x, EMPTY, 20, 0); bgm[y * W + x] = 0; } for (let y = 80; y < 140; y++) for (let x = 80; x < 160; x++) { setCell(y * W + x, EMPTY, 20, 0); bgm[y * W + x] = 0; }
window.testSilo = addMachine('silo', 28, 25, 0); window.testSilo = addMachine('silo', 28, 25, 0);
@@ -1295,10 +1306,10 @@ with sync_playwright() as p:
snapCamera(); GAME.hidePreviews = true; $('cards').replaceChildren(); snapCamera(); GAME.hidePreviews = true; $('cards').replaceChildren();
}""") }""")
fp.wait_for_timeout(300) fp.wait_for_timeout(300)
sample = "(() => { drawWorld(); const k = (235 - R.vy0) * R.bw + 300 - R.vx0; return R.px[k] & 0xffffff; })()" sample = "(() => { drawWorld(); const p=worldToScreenPx(300.5,235.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]|c[1]<<8|c[2]<<16; })()"
check('unexplored pixel is black', fp.evaluate(sample) == 0) check('unexplored pixel is black', fp.evaluate(sample) == 0)
check('sky starts seen and draws colour', fp.evaluate("(() => { const cy=R.camY; R.camY=0; drawWorld(); const c=R.px[20*R.bw+20]&0xffffff; R.camY=cy; drawWorld(); return fogSeen(300,0)===255 && c!==0x0a0a0c; })()")) check('sky starts seen and draws colour', fp.evaluate("(() => { const cy=R.camY; R.camY=0; drawWorld(); const d=worldPixelContext().getImageData(20*R.zp,20*R.zp,1,1).data,c=d[0]|d[1]<<8|d[2]<<16; R.camY=cy; drawWorld(); return fogSeen(300,0)===255 && c>0 && c!==0x0a0a0c; })()"))
check('unexplored canvas pixel is black', fp.evaluate("(() => {const s=worldToScreenPx(300.5,235.5);return Array.from(R.ctx.getImageData(s.x,s.y,1,1).data).slice(0,3).every(v=>v<=12)})()")) check('unexplored canvas pixel is black', fp.evaluate("(() => {const s=worldToScreenPx(300.5,235.5);return Array.from(worldPixelContext().getImageData(s.x,s.y,1,1).data).slice(0,3).every(v=>v<=12)})()"))
check('hover and cursor share only Unexplored', fp.evaluate("JSON.stringify(hoverInfo(300,235)) === JSON.stringify({t1:'Unexplored',t2:[],t3:[]})")) check('hover and cursor share only Unexplored', fp.evaluate("JSON.stringify(hoverInfo(300,235)) === JSON.stringify({t1:'Unexplored',t2:[],t3:[]})"))
fp.evaluate("GAME.aim = {x:300,y:235}; renderInfo()") fp.evaluate("GAME.aim = {x:300,y:235}; renderInfo()")
check('info box says only Unexplored', fp.locator('#info').inner_text() == 'Unexplored') check('info box says only Unexplored', fp.locator('#info').inner_text() == 'Unexplored')
@@ -1315,10 +1326,15 @@ with sync_playwright() as p:
check('fog toggle shows the same rock', fp.evaluate(sample) != 0) check('fog toggle shows the same rock', fp.evaluate(sample) != 0)
fp.evaluate("SET.fog = true; SET.look = 'smooth'; SM.t0 = performance.now() + 100000") fp.evaluate("SET.fog = true; SET.look = 'smooth'; SM.t0 = performance.now() + 100000")
fp.wait_for_timeout(500) fp.wait_for_timeout(500)
check('fog draws in the smooth look', fp.evaluate('R.smoothOn && SM.ok')) check('fog draws in the smooth look', fp.evaluate('R.smoothOn && GPU.ok'))
check('unseen smooth grains have no terrain shape', fp.evaluate("(() => {const k=(235-R.vy0)*R.bw+300-R.vx0;return R.px[k]===0x04000000 && R.backPx[k]===0 && R.treePx[k]===0 && R.fieldPx[k]===255})()")) fp.add_script_tag(path=str(root / 'test/gpu-readback.js'))
check('unseen smooth grains have no terrain shape', fp.evaluate("""async () => {
const k=(235-R.vy0)*R.bw+300-R.vx0;
const inputs=Array.from((await testSmoothInputs()).slice(k*4,k*4+4));
return inputs[0]===0x04000000 && inputs[1]===0 && inputs[2]===0 && inputs[3]===255;
}"""))
fp.screenshot(path=str(out / 'fog-smooth.png')) fp.screenshot(path=str(out / 'fog-smooth.png'))
fb.close() fog_context.close()
# 11. the largest world from the title screen # 11. the largest world from the title screen
pg2 = ctx.new_page() pg2 = ctx.new_page()
+7 -1
View File
@@ -30,6 +30,9 @@ def run_py(name):
def main(): def main():
sys.path.insert(0, str(ROOT / 'tools'))
from browser import consume_gpu_option
consume_gpu_option()
args = [a for a in sys.argv[1:] if not a.startswith('--')] args = [a for a in sys.argv[1:] if not a.startswith('--')]
browser = '--browser' in sys.argv or '--browser-only' in sys.argv browser = '--browser' in sys.argv or '--browser-only' in sys.argv
node = '--browser-only' not in sys.argv node = '--browser-only' not in sys.argv
@@ -42,7 +45,8 @@ def main():
if browser: if browser:
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True) subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
# one at a time: two headless browsers at once can starve each other and flake # one at a time: two headless browsers at once can starve each other and flake
for name in ['play.py', 't_smooth.py', 't_dev_console.py']: results.append(run_py(name)) for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']:
results.append(run_py(name))
bad = 0 bad = 0
for name, code, out, secs in results: for name, code, out, secs in results:
fails = [l for l in out.splitlines() if l.startswith('FAIL') or 'Error' in l or l.startswith('FAILS: [') and l != 'FAILS: []'] fails = [l for l in out.splitlines() if l.startswith('FAIL') or 'Error' in l or l.startswith('FAILS: [') and l != 'FAILS: []']
@@ -50,6 +54,8 @@ def main():
ok = code == 0 and not [l for l in fails if l.startswith('FAIL ') or 'Error' in l] ok = code == 0 and not [l for l in fails if l.startswith('FAIL ') or 'Error' in l]
bad += not ok bad += not ok
print(f"{'ok ' if ok else 'FAIL'} {name:22s} {passes:3d} checks passed {secs:5.1f}s") print(f"{'ok ' if ok else 'FAIL'} {name:22s} {passes:3d} checks passed {secs:5.1f}s")
for line in out.splitlines():
if line.startswith('SKIP '): print(' ' + line)
if not ok: if not ok:
for l in (fails or out.splitlines()[-15:]): print(' ' + l) for l in (fails or out.splitlines()[-15:]): print(' ' + l)
print('ALL PASSED' if not bad else f'{bad} SUITE(S) FAILED') print('ALL PASSED' if not bad else f'{bad} SUITE(S) FAILED')
+52
View File
@@ -0,0 +1,52 @@
"""Capability detection, title notices and the required-WebGPU startup gate."""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright
ensure_playwright()
from browser import capabilities, consume_gpu_option, launch
consume_gpu_option()
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits', 'threads']:
with launch() as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
if case not in ('native', 'threads'):
gpu = 'undefined' if case == 'absent' else "{requestAdapter: async () => null}"
if case == 'reject': gpu = "{requestAdapter: async () => {throw new Error('adapter denied')}}"
if case == 'limits': gpu="{requestAdapter:async()=>({limits:{maxStorageTexturesPerShaderStage:3}})}"
page.add_init_script(f"Object.defineProperty(navigator, 'gpu', {{value: {gpu}}});")
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});")
if case == 'threads':
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});")
if case == 'firefox':
page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});")
page.goto(url)
caps = capabilities(page)
page.wait_for_function("!$('title').hidden")
if case == 'native':
assert caps['isolated']
assert page.locator('#cap-notice').is_hidden() == caps['webgpu']
elif case == 'threads':
assert caps['webgpu'] and not caps['isolated']
assert not page.evaluate('canStartGame()')
page.evaluate('startNewWorld(424242, 512, 384)')
assert not page.evaluate('GAME.running || GAME.loading')
assert not page.evaluate('startFromSave({})')
assert 'python3 tools/serve.py' in page.inner_text('#cap-notice')
else:
assert not caps['webgpu'] and caps['adapterInfo'] is None
notice = page.inner_text('#cap-notice')
assert 'python3 tools/serve.py' in notice and "opened from a file it can't" in notice
assert ('Firefox Nightly' in notice and 'dom.webgpu.enabled' in notice) if case == 'firefox' else 'Chrome or Edge' in notice
if case != 'native':
assert not page.evaluate('canStartGame()')
page.evaluate('startNewWorld(424242, 512, 384)')
assert not page.evaluate('GAME.running || GAME.loading')
assert not page.evaluate('startFromSave({})')
if case == 'absent':
Path('shots').mkdir(exist_ok=True)
page.screenshot(path='shots/t88-p1-notice.png')
assert not errors, errors
print(f'PASS capabilities: {case}')
+2 -1
View File
@@ -123,7 +123,8 @@ const liftLanes = run('(() => { const s = new Set(); for (let y = 76; y < 80; y+
check('a lift turns its corner without squeezing down to one lane', liftLanes === 4 && lr > 30, `${lr.toFixed(0)}/s, ${liftLanes} lanes at the top`); check('a lift turns its corner without squeezing down to one lane', liftLanes === 4 && lr > 30, `${lr.toFixed(0)}/s, ${liftLanes} lanes at the top`);
fresh(); fresh();
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);'); run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
rect(run, 80, 20, 95, 99, run('SAND')); // A wide heap keeps the belt fed across the new chunk-local random streams.
rect(run, 80, 20, 111, 99, run('SAND'));
const br = rate(128, 319, 104, 119, 90, 300); const br = rate(128, 319, 104, 119, 90, 300);
ticks(run, 0); ticks(run, 0);
check('a belt carries a pile off at several dozen grains a second', br > 50, `${br.toFixed(0)}/s`); check('a belt carries a pile off at several dozen grains a second', br > 50, `${br.toFixed(0)}/s`);
+6 -6
View File
@@ -5,13 +5,13 @@ const { run } = load();
const { check, done } = suite(); const { check, done } = suite();
for (const [seed, expected] of [ for (const [seed, expected] of [
[777, '040644fb50c891911383ef0206ea988677d7ec447a3e6637cefa99364e765861'], [777, 'b6f7b493e93a59e38ee57d33f24690daa87e7be0f4b356b32225a3a6f1a9d001'],
[77, '2e6af816af54c8de4fe9c7188607f7af503ef844b2087245779bcf39e8330bdb'], [77, '5e08c24603686c8617dd1156b10612b91a05fd9ee8b3648dfc4ee32fc008383a'],
[4242, 'f09270f4ac5da9347bc277c9959e31ee797313b4a994598b4647dcf852532782'], [4242, 'f7ca75d19eba9eae4894b21b95fe1b683bd54e1f65a415dcc296f3c23b5d7e05'],
]) { ]) {
run(`generateWorld(${seed}, 512, 384)`); run(`generateWorld(${seed}, 512, 384)`);
check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true); check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true);
check(`seed ${seed} keeps its pre-seal material hash`, createHash('sha256').update(run('mat')).digest('hex') === expected); check(`seed ${seed} matches its thread-independent material hash`, createHash('sha256').update(run('mat')).digest('hex') === expected);
} }
for (const material of ['SAND', 'WATER', 'STEAM']) for (const mirror of [false, true]) { for (const material of ['SAND', 'WATER', 'STEAM']) for (const mirror of [false, true]) {
@@ -64,8 +64,8 @@ ticks(run, 600);
const pile = run(`(()=>{let x0=W,x1=0,y0=H,y1=0; const pile = run(`(()=>{let x0=W,x1=0,y0=H,y1=0;
for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mat[y*W+x]===SAND){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);} for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(mat[y*W+x]===SAND){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);}
return [x1-x0+1,y1-y0+1];})()`); return [x1-x0+1,y1-y0+1];})()`);
// Before the fix: 57 wide, 17 high. Small bounds allow random-state differences from prior scenes. // t88 chunk streams re-baseline this seeded pile to 46 wide and 19 high.
check('open-ground sand still forms the same size pile', pile[0]>=55 && pile[0]<=59 && pile[1]>=16 && pile[1]<=18 && count(run,run('SAND'))===400, pile); check('the seeded sand pile keeps its re-baselined shape and all grains', pile[0]>=44 && pile[0]<=48 && pile[1]>=18 && pile[1]<=20 && count(run,run('SAND'))===400, pile);
const sealedPile = createHash('sha256').update(run('mat')).digest('hex'), sealedRS=run('RS'); const sealedPile = createHash('sha256').update(run('mat')).digest('hex'), sealedRS=run('RS');
flatWorld(run,240,160,120); flatWorld(run,240,160,120);
run('RS=12345; CORNER_SEAL=false;'); run('RS=12345; CORNER_SEAL=false;');
+58
View File
@@ -0,0 +1,58 @@
// Ore identification, cached connected veins, ping timing, settings and label lifetime.
const fs = require('fs');
const path = require('path');
const { load, suite, flatWorld } = require('./harness');
const { run } = load();
const { check, done } = suite();
run(`const localStorage = { getItem: () => '{}', setItem() {} };`);
for (const f of ['ui/state.js', 'ui/hud.js', 'ui/cursor.js', 'ui/info.js', 'render/overlays.js']) run(fs.readFileSync(path.join(__dirname, '../src', f), 'utf8'));
flatWorld(run, 160, 160, 150);
for (const m of ['CUV', 'CUORE', 'COALV', 'COAL', 'FEV', 'FEORE', 'COPPER'])
check(m + ' is ore-like', run(`oreLike(${m}, null)`));
check('sand and stone are not ore-like', run('!oreLike(SAND, null) && !oreLike(STONE, null)'));
check('any hovered bar is ore-like', run('oreLike(EMPTY, { m: BARGL })'));
check('default labels are ores only', run("SET.cursorLabel === 'ores'"));
for (const [saved, expected] of [[true, 'ores'], [false, 'off'], ['all', 'all']]) {
run(`localStorage.getItem = () => '${JSON.stringify({cursorLabel: saved, sv: 2})}'; loadSettings();`);
check('saved ' + saved + ' becomes ' + expected, run(`SET.cursorLabel === '${expected}'`));
}
check('bit text names the first tier that can drill it', run("BIT_TIERS.every(b => { const m = Array.from(HARD).findIndex(h => h === b.hard); return m < 0 || bitNeeded(m) === 'needs ' + b.name.toLowerCase(); })"));
check('undrillable material keeps its warning', run("bitNeeded(Array.from(HARD).findIndex(h => h > 6)) === 'cannot be drilled'"));
run(`
SET.fog = false; GAME.tool = 0;
setCell(20 * W + 20, CUV, 22, F_NAT);
setCell(20 * W + 21, CUV, 22, F_NAT);
setCell(21 * W + 21, CUV, 22, F_NAT);
setCell(21 * W + 20, FEV, 22, F_NAT);
setCell(20 * W + 22, CUV, 22, 0);
setCell(22 * W + 22, CUV, 22, F_NAT);
const v = hoveredVein(20, 20);
`);
check('fill follows four neighbours of the same natural material', run('v.cells.size === 3 && v.edges.length === 8'));
check('moving within the same vein reuses cells and edges', run('hoveredVein(21, 21) === v'));
check('leaving the vein removes the outline', run('hoveredVein(22, 20) === null'));
check('a disconnected vein gets a new cache', run('hoveredVein(22, 22) !== v'));
run(`for (let y = 30; y < 130; y++) for (let x = 0; x < 100; x++) setCell(y * W + x, CUV, 22, F_NAT);`);
check('a large vein stops at 6000 cells', run('cursorVein(30 * W).cells.size === 6000'));
run(`setCell(W - 1, CUV, 22, F_NAT); setCell(W, CUV, 22, F_NAT);`);
check('fill does not wrap across world edges', run('cursorVein(W - 1).cells.size === 1'));
run(`setCell(20 * W + 20, FEV, 22, F_NAT);`);
check('hover details and warning name the needed bit', run("hoverInfo(20, 20).t2.includes(bitNeeded(FEV)) && hoverInfo(20, 20).warn === bitNeeded(FEV)"));
run(`
SET.fog = true; fog.fill(0);
const realNow = performance.now.bind(performance); let hoverNow = 1000;
performance.now = () => hoverNow;
GAME.ping = { x: 4, y: 20, t0: 1000, cells: [20 * W + 20] };
`);
check('fog hides a vein before the ping reaches it', run("hoverInfo(20, 20).t1 === 'Unexplored' && !hoveredVein(20, 20)"));
run('hoverNow = 1100;');
check('a ping names a hidden vein at arrival', run("hoverInfo(20, 20).t1 === MD[FEV].name && hoverInfo(20, 20).t2.includes('found by the ping') && !!hoveredVein(20, 20)"));
run('const pingSet = GAME.ping.cellSet; hoverNow = 3099;');
check('ping cells are cached through the fade', run('pingVisible(20, 20) && GAME.ping.cellSet === pingSet && !pingVisible(21, 20)'));
run('hoverNow = 3100;');
check('fog hides the vein again at the end of the fade', run("hoverInfo(20, 20).t1 === 'Unexplored' && !hoveredVein(20, 20)"));
run('performance.now = realNow;');
check('the name stays fully visible for two seconds', run("cursorLabelAlpha('copper', 100) === 1 && cursorLabelAlpha('copper', 2100) === 1"));
check('the name fades over 400 ms', run("cursorLabelAlpha('copper', 2300) === 0.5 && cursorLabelAlpha('copper', 2500) === 0"));
check('a different thing brings the label back', run("cursorLabelAlpha('iron', 2600) === 1"));
done();
+4 -5
View File
@@ -2,7 +2,8 @@
import pathlib, sys import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools')) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright() from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright from browser import launch, consume_gpu_option
consume_gpu_option()
ROOT = pathlib.Path(__file__).resolve().parent.parent ROOT = pathlib.Path(__file__).resolve().parent.parent
SHOTS = ROOT / 'shots' SHOTS = ROOT / 'shots'
@@ -12,13 +13,11 @@ def check(name, ok):
print(('PASS ' if ok else 'FAIL ') + name) print(('PASS ' if ok else 'FAIL ') + name)
if not ok: fails.append(name) if not ok: fails.append(name)
with sync_playwright() as p: with launch() as (browser, page, url):
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': 1280, 'height': 760})
errors = [] errors = []
page.on('pageerror', lambda e: errors.append(str(e))) page.on('pageerror', lambda e: errors.append(str(e)))
page.add_init_script("localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));") page.add_init_script("localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));")
page.goto((ROOT / 'dist/index.html').as_uri()) page.goto(url)
page.wait_for_function("!$('title').hidden") page.wait_for_function("!$('title').hidden")
page.evaluate("startNewWorld(424242, 512, 384, 'Console test')") page.evaluate("startNewWorld(424242, 512, 384, 'Console test')")
page.wait_for_function('GAME.running && !GAME.loading') page.wait_for_function('GAME.running && !GAME.loading')
+125
View File
@@ -0,0 +1,125 @@
"""Dirty uploads, default limits, warm pipelines, world replacement and device recovery."""
import pathlib, sys
sys.path.insert(0,str(pathlib.Path(__file__).resolve().parent.parent/'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from browser import launch, consume_gpu_option
consume_gpu_option()
root=pathlib.Path(__file__).resolve().parent.parent
with launch(viewport={'width':960,'height':600}) as (_,pg,url):
errors=[]
pg.on('pageerror',lambda e:errors.append(str(e)))
pg.on('console',lambda m:errors.append(m.text) if m.type=='error' else None)
pg.add_init_script('requestAnimationFrame=()=>0;')
pg.goto(url);pg.evaluate('CAPS_CHECK')
assert pg.evaluate('GPU.ok'),pg.evaluate('GPU.error')
assert pg.evaluate('GPU.device.limits.maxStorageTexturesPerShaderStage===4')
print('PASS all pipelines compile with default limits',pg.evaluate('GPU_LIMITS'))
startup=pg.evaluate("""async()=>{
await startNewWorld(424242,640,384); GAME.paused=true;
const samples=[];
for(let n=0;n<10;n++) {const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();samples.push(performance.now()-t);}
return {compile:GPU.initMs,warm:GPU.warmMs,first:samples[0],normal:samples.slice(1)};
}""")
assert startup['warm']>=0
print('PASS both looks submit behind loading screen; first visible frame ms',startup)
pg.add_script_tag(path=str(root/'tools/perf-scenes.js'))
pg.evaluate("async()=>{perfScene('factory',1024,640);await startThreads();simPool.force=true;SET.look='pixel';SET.zoom=2;resizeRender();snapCamera();drawWorld();}")
result=pg.evaluate('''async () => {
const results=[];
for(let n=0;n<240;n++) {
simTick();
if(n%20===0) {
fogLook(bot.x,bot.y); R.camX=(n*7)%400; R.camY=(n*3)%280;
SET.look=n%40?'smooth':'pixel';R.frame++;drawWorld();
results.push(await gpuCheckUploads());
}
}
drawWorld();await gpuCheckUploads();
drawWorld();return {checked:results,cleanCells:GPU.uploadCells,workers:simPool.workers.length,active:simPool.activeMax};
}''')
assert min(result['checked'])>1000 and result['cleanCells']==0 and result['workers']>0 and result['active']>0,result
print('PASS busy ticks, heat, fog, scrolling, both looks: GPU bytes equal full CPU data; clean frame uploads no world cells',result)
parity=pg.evaluate("""async()=>{
const results=[];
const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen;
try {
for(const look of ['pixel','smooth'])for(const light of ['full','fast','off'])for(const lens of [false,true])for(const fogOn of [false,true]) {
SET.look=look;SET.lighting=light;R.lens=lens;SET.fog=fogOn;R.frame++;
R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
const a=worldPixelContext().getImageData(0,0,960,600).data;
GPU.resident.fill(0);R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
const b=worldPixelContext().getImageData(0,0,960,600).data;
let max=0;for(let i=0;i<a.length;i++)max=Math.max(max,Math.abs(a[i]-b[i]));
results.push({look,light,lens,fogOn,max});
}
R.lens=false;SET.lighting='full';return results;
} finally {performance.now=now;}
}""")
assert all(r['max']==0 for r in parity),parity
print('PASS dirty and forced full uploads render identically in',len(parity),'look/light/lens/fog configurations')
cadence=pg.evaluate("""()=>{
SET.lightEvery=3;R.frame=1002;drawWorld();const old=GPU.lightFrame,origin=R.lx0;
R.camX+=4;R.frame=1003;drawWorld();const held=GPU.lightFrame===old && R.lx0===origin;
R.frame=1005;drawWorld();const updated=GPU.lightFrame===1005 && R.lx0!==origin;
SET.lightEvery=1;return {held,updated};
}""")
assert all(cadence.values()),cadence
print('PASS lighting cadence holds and then updates its camera origin')
# Force paths that can mutate sleeping chunks, away from the busy factory.
pg.evaluate('''() => {
const i=500*W+800; setCell(i,STONE,20,F_NAT);digp[i]=120;renderDirtyIdx(i);
setTreeCell(801,450,WOOD);fogRevealAll();
R.camX=650;R.camY=400;drawWorld();
}''')
assert pg.evaluate('gpuCheckUploads()')>1000
pg.evaluate('generateWorld(777,640,384,null);buildSky();resizeRender();snapCamera();drawWorld();')
assert pg.evaluate('GPU.world===mat && GPU.ok')
assert pg.evaluate('gpuCheckUploads()')>1000
print('PASS world replacement refreshes resident chunks and static data')
assert pg.evaluate('R.ctx.getImageData(10,10,1,1).data[3]===0 && worldPixelContext().getImageData(10,10,1,1).data[3]===255')
print('PASS GPU world stays opaque under transparent overlays')
cracks=pg.evaluate("""async()=>{
SET.zoom=12;SET.look='pixel';SET.fog=false;SET.lighting='off';SET.glow='off';SET.grain=0;
resizeRender();R.camX=200;R.camY=160;
for(let y=170;y<192;y++)for(let x=215;x<260;x++)setCell(y*W+x,STONE,20,F_NAT);
drawWorld();const clean=worldPixelContext().getImageData(0,0,960,600).data;
for(let y=170;y<192;y++)for(let x=215;x<260;x++){digp[y*W+x]=240;renderDirtyXY(x,y);}
R.frame=1000;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
const damaged=worldPixelContext().getImageData(0,0,960,600).data;
let changed=0;for(let i=0;i<clean.length;i+=4)changed+=clean[i]!==damaged[i];
return changed;
}""")
assert cracks>1000,cracks
pg.screenshot(path=str(root/'shots/t88-drill-pixel.png'))
pg.evaluate("SET.look='smooth';drawWorld()")
pg.screenshot(path=str(root/'shots/t88-drill-smooth.png'))
print('PASS stationary drill damage changes GPU colour in both looks',cracks)
timing=pg.evaluate('''async () => {
const originalC=GPU.device.createComputePipeline,originalR=GPU.device.createRenderPipeline;
GPU.device.createComputePipeline=GPU.device.createRenderPipeline=()=>{throw Error('synchronous pipeline compilation');};
const samples=[];
try {
for(const [look,zoom] of [['pixel',12],['smooth',12],['smooth',2],['pixel',2],['smooth',6]]) {
SET.look=look;SET.zoom=zoom;resizeRender();snapCamera();const start=performance.now();drawWorld();
await GPU.device.queue.onSubmittedWorkDone();samples.push({look,zoom,first:performance.now()-start});
const times=[];for(let n=0;n<8;n++){const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();times.push(performance.now()-t);}
samples.at(-1).normal=Math.max(...times);
}
} finally {GPU.device.createComputePipeline=originalC;GPU.device.createRenderPipeline=originalR;}
return {init:GPU.initMs,samples};
}''')
print('PASS look and zoom switches create no pipelines; timings ms',timing)
# Successful recovery must recreate resources and preserve simulation arrays.
pg.evaluate('window.savedMat=mat; GPU.device.destroy();')
pg.wait_for_function('GPU.ok && !GPU.recovering',polling=20,timeout=60000)
assert pg.evaluate('mat===savedMat')
pg.evaluate('drawWorld()')
assert pg.evaluate('gpuCheckUploads()')>1000
print('PASS device loss recreates the device once without changing the world')
pg.evaluate("Object.defineProperty(navigator,'gpu',{value:{requestAdapter:async()=>null}});GPU.device.destroy();")
pg.wait_for_function('GPU.stopped',polling=20,timeout=60000)
assert pg.evaluate('GAME.paused') and 'Reload' in pg.inner_text('#m-dialog')
assert pg.evaluate("DB.get(GAME.worldId+':auto').then(s=>!!s)")
print('PASS failed recovery stops frames, autosaves and offers Reload')
assert not errors,errors
print('PASS no page or WebGPU validation errors')

Some files were not shown because too many files have changed in this diff Show More