WebGPU pixel look: GPU colours the world, CPU only rare cells (t88 phase 2a)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:49:36 -04:00
co-authored by Claude Opus 5.5
parent b2c9ad9533
commit fc05146887
29 changed files with 949 additions and 386 deletions
+42 -3
View File
@@ -162,6 +162,13 @@ The frame loop applies speed and queued steps while keeping the per-frame tick l
- On software GL it falls back to pixels, unless `window.__allowSoftwareGL` is set (the test - On software GL it falls back to pixels, unless `window.__allowSoftwareGL` is set (the test
tools set it). tools set it).
The pixel look can use WebGPU (`src/render/gpu/`): whole-world textures receive visible
strided uploads, `colour.js` colours plain grains, and `draw.js` selects rare CPU override
blocks and submits colour, bloom and drawing. `cell.js` shares the original CPU cell rules
between Legacy/Smooth and those overrides; `objects.js` adds bars, flyers and blueprint ghosts.
The GPU canvas sits behind the transparent game canvas, which draws all existing overlays.
CPU lighting stays in `light.js`. `SET.renderer` selects WebGPU or Legacy; Smooth uses WebGL2.
## The browser shell ## The browser shell
- `GAME` (`src/ui/state.js`) holds the UI state: tool, selected piece, build tab, drag, - `GAME` (`src/ui/state.js`) holds the UI state: tool, selected piece, build tab, drag,
@@ -406,7 +413,37 @@ 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 (419 lines) ### src/render/cell.js (272 lines)
Shared CPU colour rules, used by legacy and the rare-cell WebGPU override.
- functions: cellPixel
### src/render/objects.js (80 lines)
Loose bars, flying grains and queued blueprint blocks in the grain buffer.
- functions: worldObjects
### src/render/gpu/colour.js (82 lines)
The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
- variables: GPU_COLOUR
### 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/device.js (43 lines)
Async startup shares the title screen's adapter; failures leave a usable legacy renderer.
- functions: gpuInit, gpuFail, worldPixelContext
- variables: GPU
### src/render/gpu/textures.js (84 lines)
Whole-world allocation, direct strided uploads; no cell packing or dirty tracking.
- functions: gpuTexture, gpuWrite, gpuBuffer, gpuWorldTextures, gpuViewTextures, gpuBindings, gpuUpload
### src/render/gpu/draw.js (69 lines)
Only exceptional blocks and their geometric neighbours run the CPU colour rules.
- functions: gpuOverrides, gpuDraw, gpuPresent
- variables: GPU_PATTERN
### src/render/world.js (83 lines)
Scale colour without changing the smooth-look class. Scale colour without changing the smooth-look class.
- functions: fogColor, drawWorld - functions: fogColor, drawWorld
@@ -442,7 +479,7 @@ Game shell (src/ui/): browser only. This file: settings, the GAME state object,
- 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 (74 lines) ### src/ui/title.js (76 lines)
Startup capabilities and title art. Startup capabilities and title art.
- functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle - functions: checkCapabilities, renderCapabilityNotice, canStartGame, buildTitleCells, drawTitle
- variables: REQUIRE_GPU_AND_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE - variables: REQUIRE_GPU_AND_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE
@@ -461,7 +498,7 @@ Shared player state; the world save remains a complete standalone fallback.
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 (126 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
@@ -585,6 +622,7 @@ 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`: WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss.
- `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_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.
@@ -641,3 +679,4 @@ 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-renderer.js`: Paused comparison scene: ordinary grains, fog, heat, trees and exceptional geometry.
+22 -1
View File
@@ -110,6 +110,15 @@ 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`.
- `--renderer webgpu|legacy` selects the pixel renderer (default WebGPU). WebGPU screenshots
fail clearly when no device is available; Smooth still uses WebGL2.
- `test/t_gpu.py` compares the rendered composite against Legacy for ordinary cells, built pieces,
ghosts, fog and all thermal palettes, and checks world replacement, 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
@@ -193,7 +202,7 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
simulation work per frame and number of ticks. p50/p95/p99 use interpolated sorted samples; 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. five warm-up frames are excluded. Zero-tick frames correctly have zero simulation time.
JSON also records the smooth renderer's adaptive resolution scale: compare it as well as time. JSON also records the smooth renderer's adaptive resolution scale: compare it as well as time.
Comparisons require identical options, print all timing changes, and mark >10% regressions; 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. tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change.
Performance runs launch Chromium with Performance runs launch Chromium with
@@ -212,3 +221,15 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
- `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.
WebGPU Phase 2a: Colour measures the CPU override fill (including loose objects and ghosts).
`frame_ms` is the complete paused-frame CPU call (including overlays and UI).
Upload measures strided visible-array writes and the override/light uploads, and Blit measures
GPU command submission. `override_cells` records the selected CPU cells; GPU timing is a
completed timestamp sample for that configuration across colour, bloom and drawing when `timestamp-query` is
available. The timestamp excludes uploads and the CPU lighting pass. Lighting remains on the
CPU. The GPU canvas draws the world; the transparent game canvas holds the existing overlays.
The comparison fixture is `tools/scenes/t88-renderer.js`. Capture with `--freeze --zoom 12`
(and 6 or 2), `--viewport 1920x1080`, and each `--renderer`. It includes terrain, water, fire,
magma, fog, a tree, flat/slope belts, screws, pipe, mold, bar and blueprint ghost.
+16
View File
@@ -4,6 +4,22 @@ 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-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) ## 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 - The game must be served with COOP/COEP (README has nginx and Caddy lines; opening from disk is
+7
View File
@@ -48,6 +48,13 @@
"render/light.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/colour.js",
"render/gpu/shaders.js",
"render/gpu/device.js",
"render/gpu/textures.js",
"render/gpu/draw.js",
"render/world.js", "render/world.js",
"render/machines.js", "render/machines.js",
"render/overlays.js", "render/overlays.js",
+272
View File
@@ -0,0 +1,272 @@
// Shared CPU colour rules, used by legacy and the rare-cell WebGPU override.
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 && !px[row + bx]) GPU.overrideCount++;
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.glowC.width;
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 && !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);
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);
}
+82
View File
@@ -0,0 +1,82 @@
// The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
const GPU_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<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>;
@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);
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 (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 (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(',')}); }
back+=vec3f(nz*0.06);
}
rgb=back;
if (m!=${EMPTY}u) {
rgb=(mc.rgb+vec3f(nz*mc.a*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 (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.);
}
}
// Flying grains replace colour, while the glow behind them remains.
let over=textureLoad(overrideT,q,0);
if (over.a>0.) { rgb=over.rgb; bloom=max(bloom,textureLoad(overrideGlow,q/2,0).rgb); }
textureStore(outputT,q,vec4f(rgb,1));
if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); }
}`;
+43
View File
@@ -0,0 +1,43 @@
// Async startup shares the title screen's adapter; failures leave a usable legacy renderer.
const GPU = { ok: false, canvas: document.getElementById('gpu'), error: '', gpuMs: null };
async function gpuInit(adapter) {
try {
const features = adapter.features.has('timestamp-query') ? ['timestamp-query'] : [];
const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features });
d.addEventListener('uncapturederror', e => gpuFail(e.error.message));
d.lost.then(info => gpuFail(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.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' } });
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' });
if (features.length) {
GPU.queries = d.createQuerySet({ type: 'timestamp', count: 2 });
GPU.resolve = d.createBuffer({ size: 256, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
GPU.readback = d.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
}
GPU.ok = true;
} catch (e) { gpuFail(e.message); }
}
function gpuFail(message) {
GPU.ok = false; GPU.error = message; SET.renderer = 'legacy';
GPU.canvas.hidden = true;
console.warn('WebGPU renderer: ' + message);
if (GAME.running) toast('WebGPU stopped drawing. Legacy renderer is on.');
}
// 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;
}
+69
View File
@@ -0,0 +1,69 @@
// 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]) GPU_PATTERN[m] = 1;
function gpuOverrides() {
R.px.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;
}
}
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) {
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);
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8)); compute.end();
if (glowOn) 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.glowC.width / 8), Math.ceil(R.glowC.height / 8)); pass.end();
}
gpuPresent(encoder, timed);
if (timed) { encoder.resolveQuerySet(GPU.queries, 0, 2, GPU.resolve, 0); encoder.copyBufferToBuffer(GPU.resolve, 0, GPU.readback, 0, 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.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;
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] }],
...(timed ? { timestampWrites: { querySet: GPU.queries, endOfPassWriteIndex: 1 } } : {}) });
pass.setPipeline(GPU.draw); pass.setBindGroup(0, GPU.bindDraw); pass.draw(3); 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);
}`;
+84
View File
@@ -0,0 +1,84 @@
// Whole-world allocation, direct strided uploads; no cell packing or dirty tracking.
function gpuTexture(w, h, format = 'rgba8unorm', storage = false) {
return GPU.device.createTexture({ size: [w, h], format, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | (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.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) * 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);
GPU.lut = gpuBuffer(tables);
GPU.landData = new Float32Array((W + H) * 4);
GPU.land = gpuBuffer(GPU.landData);
GPU.bindColour = null;
}
function gpuViewTextures() {
const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}`;
if (GPU.viewKey === key) return;
GPU.viewKey = key;
if (GPU.viewTextures) for (const t of GPU.viewTextures) t.destroy();
const bw = R.bw, bh = R.bh, gw = R.glowC.width, gh = R.glowC.height;
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));
GPU.viewTextures = [GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex];
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.map(view), view(GPU.override), view(GPU.overrideGlow), view(GPU.colourTex), view(GPU.glowTex)]);
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 x = Math.max(0, R.vx0 - 2), y = Math.max(0, R.vy0 - 2);
const w = Math.min(W, R.vx0 + R.bw + 2) - x, h = Math.min(H, R.vy0 + R.bh + 2) - y;
const arrays = [mat, flags, life, part, bgm, digp];
arrays.forEach((a, i) => gpuWrite(GPU.worldTextures[i], a, W, x, y, w, h));
gpuWrite(GPU.temp, temp, W, x, y, w, h);
const fx = Math.max(0, Math.floor(x / FOG)), fy = Math.max(0, Math.floor(y / FOG));
gpuWrite(GPU.worldTextures[7], fog, FW, fx, fy, Math.min(FW, Math.ceil((x + w) / FOG) + 1) - fx, Math.min(FH, Math.ceil((y + h) / FOG) + 1) - fy);
// 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.glowC.width, 0, 0, R.glowC.width, R.glowC.height);
if (lighting) gpuWrite(GPU.lightTex, R.lightPx, R.lw, 0, 0, R.lw, R.lh);
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]));
}
+1 -1
View File
@@ -99,5 +99,5 @@ function computeLight() {
r = r >= 1 ? 255 : (r * 255) | 0; g = g >= 1 ? 255 : (g * 255) | 0; b = b >= 1 ? 255 : (b * 255) | 0; 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; px[k] = 0xff000000 | (b << 16) | (g << 8) | r;
} }
if (!R.smoothOn) R.lightX.putImageData(R.lightImg, 0, 0); if (!R.smoothOn && !R.gpuOn) 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 && !px[sy * bw + sx]) 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 && !px[sy * bw + sx]) 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 && !px[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);
}
}
}
}
+1 -1
View File
@@ -138,7 +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.buf = document.createElement('canvas');
R.bctx = R.buf.getContext('2d'); R.bctx = R.buf.getContext('2d');
R.glowC = document.createElement('canvas'); R.glowC = document.createElement('canvas');
+15 -351
View File
@@ -3,18 +3,20 @@ 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 perfStart = PERF_MARKS ? performance.now() : 0; const perfStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
if (PERF_MARKS) perfMarks.light = 0; if (PERF_MARKS || SET.showPerf) { perfMarks.light = 0; perfMarks.override = 0; delete perfMarks.gpu; }
const bw = R.bw, bh = R.bh, px = R.px; R.gpuOn = SET.renderer === 'webgpu' && SET.look === 'pixel' && GPU.ok;
GPU.canvas.hidden = !R.gpuOn;
if (R.gpuOn) 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 look: the colours below are still worked out per grain, but each grain also gets a // Smooth look: the colours below are still worked out per grain, but each grain also gets a
// class (alpha byte) and the GPU draws rounded shapes from them (see smooth-shaders.js) // class (alpha byte) and the GPU draws rounded shapes from them (see smooth-shaders.js)
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && typeof smoothAvailable === 'function' && smoothAvailable(); 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; const mmask = R.moldMask;
if (sm) { if (sm) {
mmask.fill(0); mmask.fill(0);
for (const mo of molds) { for (const mo of molds) {
@@ -30,361 +32,23 @@ 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)) { if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) {
const lightStart = PERF_MARKS ? performance.now() : 0; const lightStart = PERF_MARKS ? performance.now() : 0;
computeLight(); computeLight();
if (PERF_MARKS) perfMarks.light = performance.now() - lightStart; if (PERF_MARKS) perfMarks.light = performance.now() - lightStart;
} }
const colorStart = PERF_MARKS ? performance.now() : 0; const colorStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
const grain = SET.grain, incand = SET.incandescence; const glowOn = SET.glow !== 'off' && !R.lens;
const anim = R.frame; if (glowOn) R.glowPx.fill(0);
const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno; if (R.gpuOn) gpuOverrides();
const glowOn = SET.glow !== 'off' && !lens; else for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) cellPixel(vx0 + bx, vy0 + by);
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++) {
const y = vy0 + by;
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 (PERF_MARKS) {
perfMarks.color = performance.now() - colorStart; perfMarks.color = performance.now() - colorStart;
perfMarks.prepare = colorStart - perfStart - perfMarks.light; perfMarks.prepare = colorStart - perfStart - perfMarks.light;
} }
if (R.gpuOn) { gpuDraw(lighting, glowOn); return; }
if (sm) { smoothDraw(lighting, glowOn); return; } if (sm) { smoothDraw(lighting, glowOn); return; }
const uploadStart = PERF_MARKS ? performance.now() : 0; const uploadStart = PERF_MARKS ? performance.now() : 0;
R.bctx.putImageData(R.img, 0, 0); R.bctx.putImageData(R.img, 0, 0);
+1
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>
+4 -1
View File
@@ -29,7 +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: none; } /* the game draws its own aim */ 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 */
/* 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; }
@@ -399,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; }
+1 -1
View File
@@ -101,7 +101,7 @@ function frame(now) {
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 (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 · light ${(perfTimes.computeLight || 0).toFixed(1)} ms · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\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 · light ${(perfTimes.computeLight || 0).toFixed(1)} ms${R.gpuOn ? ` · overrides ${(perfMarks.override || 0).toFixed(1)} · upload ${(perfMarks.upload || 0).toFixed(1)} · GPU ${GPU.gpuMs === null ? "—" : GPU.gpuMs.toFixed(1)}` : ""} · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
} }
} }
+1
View File
@@ -16,6 +16,7 @@ const SET_TABS = {
{ k: 'shake', label: 'Screen shake', type: 'toggle' }, { k: 'shake', label: 'Screen shake', type: 'toggle' },
], ],
Graphics: [ Graphics: [
{ k: 'renderer', label: 'Renderer', d: 'WebGPU colours the pixel look on the graphics card. Smooth uses the original renderer.', type: 'select', opts: [['webgpu', 'WebGPU'], ['legacy', 'Legacy']] },
{ k: 'look', label: 'Look', d: 'Smooth draws rounded, outlined ground, round grains and glossy liquids on the graphics card. Pixel is the original look, one square per grain.', type: 'select', opts: [['smooth', 'Smooth'], ['pixel', 'Pixel']] }, { k: 'look', label: 'Look', d: 'Smooth draws rounded, outlined ground, round grains and glossy liquids on the graphics card. Pixel is the original look, one square per grain.', type: 'select', opts: [['smooth', 'Smooth'], ['pixel', 'Pixel']] },
{ k: 'lighting', label: 'Lighting', d: 'Full is colored light at grain resolution. Fast uses half resolution.', type: 'select', opts: [['off', 'Off'], ['fast', 'Fast'], ['full', 'Full']] }, { k: 'lighting', label: 'Lighting', d: 'Full is colored light at grain resolution. Fast uses half resolution.', type: 'select', opts: [['off', 'Off'], ['fast', 'Fast'], ['full', 'Full']] },
{ k: 'lightEvery', label: 'Light updates', type: 'select', opts: [[1, 'Every frame'], [2, 'Every 2nd frame'], [3, 'Every 3rd frame']] }, { k: 'lightEvery', label: 'Light updates', type: 'select', opts: [[1, 'Every frame'], [2, 'Every 2nd frame'], [3, 'Every 3rd frame']] },
+1 -1
View File
@@ -4,7 +4,7 @@
// ---------- Settings ---------- // ---------- Settings ----------
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, renderer: 'webgpu', 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,
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: 'ores', sv: 2, deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: 'ores', sv: 2,
+3 -1
View File
@@ -5,9 +5,11 @@ async function checkCapabilities() {
try { try {
const adapter = navigator.gpu && await navigator.gpu.requestAdapter(); const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
CAPS.webgpu = !!adapter; CAPS.webgpu = !!adapter;
if (adapter) await gpuInit(adapter);
else SET.renderer = 'legacy';
const info = adapter && adapter.info; const info = adapter && adapter.info;
if (info) CAPS.adapterInfo = { vendor: info.vendor, architecture: info.architecture, device: info.device, description: info.description }; if (info) CAPS.adapterInfo = { vendor: info.vendor, architecture: info.architecture, device: info.device, description: info.description };
} catch (e) { CAPS.webgpu = false; } } catch (e) { CAPS.webgpu = false; SET.renderer = 'legacy'; }
CAPS.ready = true; CAPS.ready = true;
renderCapabilityNotice(); renderCapabilityNotice();
} }
+3 -3
View File
@@ -1308,10 +1308,10 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
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')
+1 -1
View File
@@ -45,7 +45,7 @@ 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', 't_caps.py', 'firefox_smoke.py']: for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 'firefox_smoke.py']:
results.append(run_py(name)) results.append(run_py(name))
bad = 0 bad = 0
for name, code, out, secs in results: for name, code, out, secs in results:
+81
View File
@@ -0,0 +1,81 @@
"""WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss."""
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
gpu_mode = consume_gpu_option()
root = pathlib.Path(__file__).resolve().parent.parent
with launch(viewport={'width': 960, 'height': 600}) as (browser, 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')
if gpu_mode == 'soft' and not pg.evaluate('CAPS.webgpu'):
print('SKIP WebGPU parity: software browser has no WebGPU adapter')
sys.exit(0)
assert pg.evaluate('GPU.ok'), pg.evaluate('GPU.error')
pg.evaluate('generateWorld(424242, 640, 384, null); enterGame(); GAME.paused=true;')
pg.evaluate('() => {' + (root / 'tools/scenes/t88-renderer.js').read_text() + '}')
result = pg.evaluate("""() => {
SET.look='pixel'; SET.zoom=6; SET.glow='off'; SET.lighting='off';
resizeRender(); bot.x=CAM[0]-bot.w/2; bot.y=CAM[1]-bot.h/2; snapCamera(); R.frame=1000;
const results=[];
for(const rain of [0,0.7])for(const lit of [false,true])for(const lens of [false,true])for(const palette of ['inferno','ironbow','gray'])for(const fogOn of [false,true]) {
SET.lighting=lit?'full':'off'; SET.glow=lit?'low':'off';
R.lens=lens; SET.thermalPalette=palette; SET.fog=fogOn;
R.rain=rain; SET.renderer='legacy';drawWorld(); const a=worldPixelContext().getImageData(0,0,960,600).data;
R.rain=rain; SET.renderer='webgpu';drawWorld(); const b=worldPixelContext().getImageData(0,0,960,600).data;
let max=0, sum=0, bad=0;
for(let i=0;i<a.length;i+=4) {let d=0;for(let j=0;j<3;j++){const e=Math.abs(a[i+j]-b[i+j]);sum+=e;d=Math.max(d,e);}max=Math.max(max,d);bad+=d>8;}
results.push({rain,lit,lens,palette,fogOn,max,mean:sum/(960*600*3),bad});
}
R.lens=false;
return results;
}""")
for r in result:
assert r['bad'] == 0, r
print('PASS plain cells, built geometry, ghosts, rain and all thermal palettes match legacy within 8', len(result), 'configurations; max', max(r['max'] for r in result))
assert pg.evaluate('GPU.overrideCount > 0 && GPU.overrideCount < R.bw*R.bh*0.25')
alpha = pg.evaluate('[R.gpuOn,GPU.ok,GPU.error,R.ctx.getImageData(10,10,1,1).data[3],worldPixelContext().getImageData(10,10,1,1).data[3],R.canvas.width,R.canvas.height]')
assert alpha[3:5] == [0,255], alpha
print('PASS most visible grains use GPU colouring; pixel reads include the opaque GPU world')
glow = pg.evaluate("""() => {
SET.fog=false; SET.lighting='off'; SET.glow='high'; R.lens=false;
spawnBar(BARFE,CAM[0]-30,CAM[1]+15,0,0,20);
const x=CAM[0]-64,y=CAM[1]-36;
setCell(y*W+x,MAGMA,1450,F_NAT);
flyers.push({x,y,m:COAL,rain:false});
SET.renderer='legacy';drawWorld();const a=worldPixelContext().getImageData(0,0,960,600).data;
SET.renderer='webgpu';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data;
let max=0;for(let i=0;i<a.length;i++)if((i&3)!==3)max=Math.max(max,Math.abs(a[i]-b[i]));return max;
}""")
assert glow <= 8, f'Objects must retain the hot grain glow beneath them: max error {glow}'
print('PASS bars and flying grains preserve the glow behind them', glow)
# Fresh arrays, same dimensions: catches stale world/static textures after a load.
pg.evaluate('generateWorld(777, 640, 384, null); buildSky(); SET.renderer="webgpu"; SET.zoom=2; resizeRender(); snapCamera(); drawWorld();')
assert pg.evaluate('GPU.world===mat && GPU.colourTex.width===R.bw && GPU.ok')
parity = pg.evaluate("""() => {
const a=worldPixelContext().getImageData(0,0,960,600).data;
SET.renderer='legacy';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data;
SET.renderer='webgpu'; let max=0,nonblack=0;
for(let i=0;i<a.length;i+=4){nonblack+=a[i]+a[i+1]+a[i+2]>0;for(let j=0;j<3;j++)max=Math.max(max,Math.abs(a[i+j]-b[i+j]));}
return {max,nonblack};
}""")
assert parity['max'] <= 8 and parity['nonblack'] > 1000, parity
print('PASS world replacement and zoom reallocate textures and draw the new world', parity)
pg.evaluate('SET.look="smooth"; drawWorld();')
assert pg.evaluate('!R.gpuOn && R.smoothOn && GPU.canvas.hidden')
print('PASS smooth keeps the legacy path')
pg.evaluate('SET.look="pixel"; SET.renderer="webgpu"; PERF_MARKS=true; drawWorld();')
pg.wait_for_function('!GPU.timingBusy')
assert pg.evaluate('!GPU.queries || GPU.gpuMs>=0')
assert pg.evaluate('perfMarks.override>=0 && perfMarks.upload>=0')
print('PASS separate override/upload clocks and optional GPU timestamps')
pg.evaluate('GPU.device.destroy()')
pg.wait_for_function('!GPU.ok')
assert pg.evaluate('SET.renderer==="legacy"')
pg.evaluate('drawWorld()')
print('PASS device loss restores the legacy renderer')
assert not errors, errors
print('PASS no page or WebGPU validation errors')
+10 -9
View File
@@ -21,7 +21,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
ev = pg.evaluate ev = pg.evaluate
check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && SM.ok && R.smoothOn"), ev('[SET.look, SM.ok, SM.why, R.smoothOn]')) check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && SM.ok && R.smoothOn"), ev('[SET.look, SM.ok, SM.why, R.smoothOn]'))
# the canvas shows the GPU image: sample a sky pixel and a ground pixel, they must differ and not be black # the canvas shows the GPU image: sample a sky pixel and a ground pixel, they must differ and not be black
px = ev("""(() => { const c = R.ctx.getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = R.ctx.getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""") px = ev("""(() => { const c = worldPixelContext().getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = worldPixelContext().getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""")
check('it draws sky and ground', sum(px[0][:3]) > 200 and px[0][:3] != px[1][:3], px) check('it draws sky and ground', sum(px[0][:3]) > 200 and px[0][:3] != px[1][:3], px)
pg.screenshot(path=str(out / 'smooth.png')) pg.screenshot(path=str(out / 'smooth.png'))
ev("player.lens = true"); pg.keyboard.press('h'); pg.wait_for_timeout(1500) ev("player.lens = true"); pg.keyboard.press('h'); pg.wait_for_timeout(1500)
@@ -43,8 +43,9 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
const i = y * W + x; const i = y * W + x;
setCell(i, STONE, 22, F_NAT); bgm[i] = 0; noiseB[i] = 128; setCell(i, STONE, 22, F_NAT); bgm[i] = 0; noiseB[i] = 128;
} }
GPU.world = null; // The fixture changed static noise in place.
const fill = m => { for (let y = 100; y < 140; y++) for (let x = 100; x < 180; x++) setCell(y * W + x, m, 22); }; const fill = m => { for (let y = 100; y < 140; y++) for (let x = 100; x < 180; x++) setCell(y * W + x, m, 22); };
const at = (x, y) => { const c = R.px[(y - R.vy0) * R.bw + x - R.vx0]; return (c & 255) + (c >>> 8 & 255) + (c >>> 16 & 255); }; const at = (x, y) => { const p=worldToScreenPx(x+0.5,y+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]+c[1]+c[2]; };
const out = {}; const out = {};
for (const lens of [false, true]) { for (const lens of [false, true]) {
R.lens = lens; SET.look = 'pixel'; fill(RSTONE); drawWorld(); R.lens = lens; SET.look = 'pixel'; fill(RSTONE); drawWorld();
@@ -59,7 +60,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
fill(RSTONE); SET.look = 'smooth'; SM.scale = 1; drawWorld(); fill(RSTONE); SET.look = 'smooth'; SM.scale = 1; drawWorld();
const sample = (x, y) => { const sample = (x, y) => {
const p = worldToScreenPx(x, y); const p = worldToScreenPx(x, y);
const c = R.ctx.getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data; const c = worldPixelContext().getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data;
return c[0] + c[1] + c[2]; return c[0] + c[1] + c[2];
}; };
out.smooth = [sample(140.5, 140.08), sample(140.5, 142.5)]; out.smooth = [sample(140.5, 140.08), sample(140.5, 142.5)];
@@ -140,8 +141,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
const x=cell%W,y=(cell/W)|0,control=80*W+180; const x=cell%W,y=(cell/W)|0,control=80*W+180;
bot.x=118;bot.y=78;snapCamera(); bot.x=118;bot.y=78;snapCamera();
setCell(cell,SAND,22,0);setCell(control,SAND,22,0); setCell(cell,SAND,22,0);setCell(control,SAND,22,0);
noiseB[cell]=noiseB[control]=128; noiseB[cell]=noiseB[control]=128; GPU.world=null;
const at=i=>R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0]; const at=i=>{ if(R.smoothOn) return R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0]; const p=worldToScreenPx(i%W+0.5,((i/W)|0)+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]|c[1]<<8|c[2]<<16; };
SET.look='pixel';drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff]; SET.look='pixel';drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff];
SET.look='smooth';drawWorld();const grainClass=at(cell)>>>24; SET.look='smooth';drawWorld();const grainClass=at(cell)>>>24;
return {colors,grainClass}; return {colors,grainClass};
@@ -162,7 +163,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
for(const look of ["pixel","smooth"]) { for(const look of ["pixel","smooth"]) {
SET.look=look;SM.scale=1;drawWorld(); SET.look=look;SM.scale=1;drawWorld();
const bad=[], copper=c=>c[0]>c[1]*1.15 && c[1]>c[2]*1.1; const bad=[], copper=c=>c[0]>c[1]*1.15 && c[1]>c[2]*1.1;
const sample=(x,y)=>{const p=worldToScreenPx(x,y);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);}; const sample=(x,y)=>{const p=worldToScreenPx(x,y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
for(const j of JOIN_CASES.filter(j=>j.code===BP_SCREW)) { for(const j of JOIN_CASES.filter(j=>j.code===BP_SCREW)) {
const slope=j.v===4 || j.v===6?-1:1, side=j.end?1:-1; const slope=j.v===4 || j.v===6?-1:1, side=j.end?1:-1;
const joins=diagonalScrewJoins(j.bx,j.by,j.v); const joins=diagonalScrewJoins(j.bx,j.by,j.v);
@@ -211,7 +212,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
const b=j.by*BW+j.bx,nb=j.ny*BW+j.nx; const b=j.by*BW+j.bx,nb=j.ny*BW+j.nx;
const plan=new Map([[b,[BP_SCREW,j.v]],[nb,[BP_SCREW,j.straight]]]); const plan=new Map([[b,[BP_SCREW,j.v]],[nb,[BP_SCREW,j.straight]]]);
removeBlock(j.bx,j.by);removeBlock(j.nx,j.ny); removeBlock(j.bx,j.by);removeBlock(j.nx,j.ny);
const sample=(x,y)=>{const p=worldToScreenPx(j.nx*4+x,j.ny*4+y);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data).join(',');}; const sample=(x,y)=>{const p=worldToScreenPx(j.nx*4+x,j.ny*4+y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data).join(',');};
SM.scale=1;drawWorld();const bg=sample(ux,uy),empty=sample(2,2); SM.scale=1;drawWorld();const bg=sample(ux,uy),empty=sample(2,2);
for(const [n,piece] of plan)ghostBlocks.set(n,piece); for(const [n,piece] of plan)ghostBlocks.set(n,piece);
drawWorld(); drawWorld();
@@ -236,7 +237,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
try { try {
const j=JOIN_CASES.find(j=>j.v===5 && j.end===1 && !j.vertical && j.spot===2 && j.code===BP_SCREW); const j=JOIN_CASES.find(j=>j.v===5 && j.end===1 && !j.vertical && j.spot===2 && j.code===BP_SCREW);
const bx=j.nx+1,by=j.ny; const bx=j.nx+1,by=j.ny;
const sample=()=>{const p=worldToScreenPx(bx*4,by*4);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');}; const sample=()=>{const p=worldToScreenPx(bx*4,by*4);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');};
drawWorld();const plain=sample(),bad=[]; drawWorld();const plain=sample(),bad=[];
for(const m of [WOOD,LEAVES]) { for(const m of [WOOD,LEAVES]) {
for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=m; for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=m;
@@ -252,7 +253,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
const j=JOIN_CASES.find(j=>j.v===4&&j.end===0&&j.code===BP_BELT&&j.spot===0); const j=JOIN_CASES.find(j=>j.v===4&&j.end===0&&j.code===BP_BELT&&j.spot===0);
try { try {
slopeDetailAt=()=>0;performance.now=()=>frozen; slopeDetailAt=()=>0;performance.now=()=>frozen;
const sample=()=>{const p=worldToScreenPx(j.bx*BS+0.4,j.by*BS+2.5);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);}; const sample=()=>{const p=worldToScreenPx(j.bx*BS+0.4,j.by*BS+2.5);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
for(const look of ['pixel','smooth']) { for(const look of ['pixel','smooth']) {
SET.look=look;SM.scale=1;drawWorld();const joined=sample(); SET.look=look;SM.scale=1;drawWorld();const joined=sample();
removeBlock(j.nx,j.ny);drawWorld();const free=sample(); removeBlock(j.nx,j.ny);drawWorld();const free=sample();
+14 -5
View File
@@ -29,7 +29,7 @@ function perfSim(names, scene, w, h) {
return { total_ms: total, ticks: n, start, end: perfState(), steps, return { total_ms: total, ticks: n, start, end: perfState(), steps,
rest_ms: total - Object.values(data).reduce((s, d) => s + d.ms / n, 0) }; rest_ms: total - Object.values(data).reduce((s, d) => s + d.ms / n, 0) };
} }
function perfRender(lighting, overlays) { async function perfRender(lighting, overlays) {
SET.lighting = lighting; SET.lighting = lighting;
PERF_MARKS = true; PERF_MARKS = true;
try { try {
@@ -48,13 +48,22 @@ function perfRender(lighting, overlays) {
// Exercise the actual overlay calls and their arguments/conditions in frame(). // Exercise the actual overlay calls and their arguments/conditions in frame().
PERF_MARKS = false; PERF_MARKS = false;
for (let k = 0; k < 5; k++) frame(lastT + 1000 / 60); for (let k = 0; k < 5; k++) frame(lastT + 1000 / 60);
const data = {}, restore = perfWrap(overlays, data); const data = {}, restore = perfWrap(overlays, data), frameStart = performance.now();
try { for (let k = 0; k < n; k++) frame(lastT + 1000 / 60); } try { for (let k = 0; k < n; k++) frame(lastT + 1000 / 60); }
finally { restore(); } finally { restore(); }
return { drawWorld_ms: draw, computeLight_ms: light, marks_ms: marks, const frame_ms = (performance.now() - frameStart) / n;
// One completed timestamp sample for this configuration, outside the CPU measurements.
let gpu_ms = null;
if (R.gpuOn && GPU.queries) {
await GPU.timingPromise;
PERF_MARKS = true; drawWorld();
await GPU.timingPromise;
PERF_MARKS = false; gpu_ms = GPU.gpuMs; marks.gpu = gpu_ms;
}
return { frame_ms, drawWorld_ms: draw, computeLight_ms: light, marks_ms: marks,
overlays: Object.fromEntries(Object.entries(data).map(([key, d]) => [key, { ms_per_frame: d.ms / n, calls: d.calls }])), overlays: Object.fromEntries(Object.entries(data).map(([key, d]) => [key, { ms_per_frame: d.ms / n, calls: d.calls }])),
overlay_ms: Object.values(data).reduce((sum, d) => sum + d.ms / n, 0), overlay_ms: Object.values(data).reduce((sum, d) => sum + d.ms / n, 0),
scale: SM.scale, actual_look: SET.look }; scale: SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms };
} finally { PERF_MARKS = false; } } finally { PERF_MARKS = false; }
} }
function perfRun(seconds) { function perfRun(seconds) {
@@ -72,7 +81,7 @@ function perfRun(seconds) {
requestAnimationFrame(frame); requestAnimationFrame(frame);
setTimeout(() => { setTimeout(() => {
window.__perfHalt(); PERF_FRAME = null; GAME.paused = true; window.__perfHalt(); PERF_FRAME = null; GAME.paused = true;
resolve({ samples, end: perfState(), scale: SM.scale, actual_look: SET.look }); resolve({ samples, end: perfState(), scale: SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms: R.gpuOn ? GPU.gpuMs : null });
}, seconds * 1000); }, seconds * 1000);
}); });
} }
+14 -4
View File
@@ -54,14 +54,14 @@ def report(result, baseline=None):
m = result['machine'] m = result['machine']
print(f"GPU: {m['renderer']}\nCores: {m['cores']} | isolated: {m['crossOriginIsolated']} | navigator.gpu: {m['webgpu']}") print(f"GPU: {m['renderer']}\nCores: {m['cores']} | isolated: {m['crossOriginIsolated']} | navigator.gpu: {m['webgpu']}")
print(f"Viewport {result['options']['viewport']} | world {result['options']['world']} | seed 424242 | ms (CPU wall time)") print(f"Viewport {result['options']['viewport']} | world {result['options']['world']} | seed 424242 | ms (CPU wall time)")
print('\nScene Look Zoom Light Grains World Light Prep Colour Upload Blit Overlays Scale') print('\nScene Look Zoom Light Grains World Frame Light Prep Colour Upload Blit Overlays Scale')
for scene, data in result['scenes'].items(): for scene, data in result['scenes'].items():
for key, view in data['views'].items(): for key, view in data['views'].items():
look, zoom = key.split('/') look, zoom = key.split('/')
for light, r in view['lighting'].items(): for light, r in view['lighting'].items():
p = r['marks_ms'] p = r['marks_ms']
print(f"{scene:8} {look:6} {zoom:>4} {light:>5} {view['grains']:6d} " print(f"{scene:8} {look:6} {zoom:>4} {light:>5} {view['grains']:6d} "
f"{r['drawWorld_ms']:7.2f} {p['light']:6.2f} {p['prepare']:5.2f} {p['color']:6.2f} " f"{r['drawWorld_ms']:7.2f} {r.get('frame_ms', 0):6.2f} {p['light']:6.2f} {p['prepare']:5.2f} {p['color']:6.2f} "
f"{p['upload']:6.2f} {p['blit']:6.2f} {r['overlay_ms']:8.3f} {r['scale']:5.2f}") f"{p['upload']:6.2f} {p['blit']:6.2f} {r['overlay_ms']:8.3f} {r['scale']:5.2f}")
print('\nSimulation: average ms/tick (periodic work amortized over 240 ticks)') print('\nSimulation: average ms/tick (periodic work amortized over 240 ticks)')
names = list(next(iter(result['scenes'].values()))['sim']['steps']) names = list(next(iter(result['scenes'].values()))['sim']['steps'])
@@ -125,8 +125,10 @@ def main():
options = dict(viewport=list(args.viewport), world=list(args.world), zooms=zooms, options = dict(viewport=list(args.viewport), world=list(args.world), zooms=zooms,
looks=looks, scenes=scenes, seconds=args.seconds) looks=looks, scenes=scenes, seconds=args.seconds)
baseline = json.loads(args.compare.read_text()) if args.compare else None baseline = json.loads(args.compare.read_text()) if args.compare else None
if baseline and (baseline.get('schema') != 1 or baseline['options'] != options): if baseline and (baseline.get('schema') != 1
ap.error('baseline schema/options differ; use the same matrix, dimensions and seconds') or any(baseline['options'].get(k) != v for k, v in options.items() if k not in ('looks', 'scenes', 'zooms'))
or any(not set(options[k]).issubset(baseline['options'][k]) for k in ('looks', 'scenes', 'zooms'))):
ap.error('baseline schema/options differ; use a subset of the baseline matrix with the same dimensions and seconds')
ensure_playwright() ensure_playwright()
started = time.monotonic() started = time.monotonic()
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True, stdout=sys.stderr) subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True, stdout=sys.stderr)
@@ -154,6 +156,9 @@ def main():
const id = raf(t => { pending.delete(id); f(t); }); pending.add(id); return id; const id = raf(t => { pending.delete(id); f(t); }); pending.add(id); return id;
};''') };''')
pg.goto(url) pg.goto(url)
pg.evaluate('CAPS_CHECK')
if not pg.evaluate('GPU.ok'):
raise RuntimeError('WebGPU unavailable: ' + pg.evaluate('GPU.error'))
pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text()) pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text())
pg.add_script_tag(content=(ROOT / 'tools/perf-measure.js').read_text()) pg.add_script_tag(content=(ROOT / 'tools/perf-measure.js').read_text())
result['machine'] = pg.evaluate('''() => { result['machine'] = pg.evaluate('''() => {
@@ -162,6 +167,7 @@ def main():
return { renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER), return { renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR), vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
cores: navigator.hardwareConcurrency, crossOriginIsolated, webgpu: !!navigator.gpu, cores: navigator.hardwareConcurrency, crossOriginIsolated, webgpu: !!navigator.gpu,
adapter: CAPS.adapterInfo, timestamp_query: !!GPU.queries,
user_agent: navigator.userAgent, dpr: devicePixelRatio }; user_agent: navigator.userAgent, dpr: devicePixelRatio };
}''') }''')
if not result['machine']['crossOriginIsolated']: if not result['machine']['crossOriginIsolated']:
@@ -185,6 +191,8 @@ def main():
view['lighting'][light] = pg.evaluate('([l,o]) => perfRender(l,o)', [light, overlays]) view['lighting'][light] = pg.evaluate('([l,o]) => perfRender(l,o)', [light, overlays])
if view['lighting']['full']['actual_look'] != look or view['lighting']['off']['actual_look'] != look: if view['lighting']['full']['actual_look'] != look or view['lighting']['off']['actual_look'] != look:
raise RuntimeError('Look fell back while measuring') raise RuntimeError('Look fell back while measuring')
if look == 'pixel' and any(r['renderer'] != 'webgpu' for r in view['lighting'].values()):
raise RuntimeError('WebGPU fell back while measuring: ' + pg.evaluate('GPU.error'))
data['views'][f'{look}/{zoom:g}'] = view data['views'][f'{look}/{zoom:g}'] = view
for look in looks: for look in looks:
for zoom in [2, 6]: for zoom in [2, 6]:
@@ -193,6 +201,8 @@ def main():
r = pg.evaluate('seconds => perfRun(seconds)', args.seconds) r = pg.evaluate('seconds => perfRun(seconds)', args.seconds)
if r['actual_look'] != look: if r['actual_look'] != look:
raise RuntimeError('Look fell back during real run') raise RuntimeError('Look fell back during real run')
if look == 'pixel' and r['renderer'] != 'webgpu':
raise RuntimeError('WebGPU fell back during real run: ' + pg.evaluate('GPU.error'))
for key in ['interval', 'work', 'sim']: for key in ['interval', 'work', 'sim']:
r[key + '_ms'] = quantiles([s[key] for s in r['samples']]) r[key + '_ms'] = quantiles([s[key] for s in r['samples']])
data['runs'][f'{look}/{zoom}'] = r data['runs'][f'{look}/{zoom}'] = r
+29
View File
@@ -0,0 +1,29 @@
// Paused comparison scene: ordinary grains, fog, heat, trees and exceptional geometry.
const cx = (W / 3) & ~3, cy = 180;
window.CAM = [cx, cy];
SET.fog = true; SET.shake = false; SET.cameraLead = false;
weather.next = 1e15; R.rain = 0;
for (let y = cy - 52; y < cy + 52; y++) for (let x = cx - 90; x < cx + 90; x++) {
const i = y * W + x;
setCell(i, y >= cy + 24 ? STONE : EMPTY, 20, y >= cy + 24 ? F_NAT : 0);
bgm[i] = part[i] = mach[i] = occ[i] = 0; blockP[(y >> 2) * BW + (x >> 2)] = 0;
}
for (let x = cx - 90; x < cx + 90; x++) { surfY[x] = cy + 16; dirtY[x] = cy + 30; graniteY[x] = cy + 38; basaltY[x] = cy + 46; }
const fill = (x0,y0,w,h,m,T=20) => { for(let y=y0;y<y0+h;y++)for(let x=x0;x<x0+w;x++)setCell(y*W+x,m,T,F_NAT); };
fill(cx-62,cy+6,22,16,WATER); fill(cx-34,cy+11,16,11,MAGMA,1450);
fill(cx-33,cy+3,8,8,FIRE,900); fill(cx-12,cy+17,15,6,SAND);
fill(cx-10,cy+24,12,5,STONE,1100);
for(let y=cy-20;y<cy+16;y++)for(let x=cx-58;x<cx-54;x++)bgm[y*W+x]=WOOD;
for(let y=cy-28;y<cy-12;y++)for(let x=cx-68;x<cx-44;x++)if((x-cx+56)**2+(y-cy+20)**2<130)bgm[y*W+x]=LEAVES;
for(let x=cx-40;x<cx-34;x++)digp[(cy+24)*W+x]=160;
const place=(code,v,dx,dy)=>{const e=placeBlock(code,v,(cx+dx)>>2,(cy+dy)>>2,true);if(e)throw Error(e);};
for(let dx=-36;dx<-12;dx+=4)place(BP_BELT,1,dx,-12);
place(BP_SCREW,1,-4,-12); place(BP_SCREW,4,0,-12);
place(BP_BELT_UP,1,16,-12);place(BP_BELT_UP,1,20,-16);
place(BP_PIPE,0,32,-12);place(BP_PIPE,0,36,-12);
addMold('ingot',(cx+8)>>2,(cy+8)>>2,RSTONE,true);
spawnBar(BARCU,cx-25,cy-16,0,0,700);
flyers.push({x:cx-8,y:cy-18,m:SAND,rain:false});
ghostBlocks.set(((cy-4)>>2)*BW+((cx-4)>>2),[BP_WALL,RSTONE]);
for(let j=0;j<FH;j++)for(let i=0;i<FW;i++)fog[j*FW+i]=Math.max(0,Math.min(255,(cx+72-i*FOG)*5));
updateRoof(); rebuildPower();
+7 -1
View File
@@ -13,6 +13,7 @@ code and the bot is moved there before the shot. SX, SY are the bot's starting p
Options: Options:
--look pixel|smooth which look to draw (default: pixel; use smooth only to check graphics) --look pixel|smooth which look to draw (default: pixel; use smooth only to check graphics)
--renderer webgpu|legacy pixel renderer (default: webgpu)
--seed N world seed (default 424242) --seed N world seed (default 424242)
--size WxH world size in grains (default 2048x1024) --size WxH world size in grains (default 2048x1024)
--setup FILE.js code to run once the world is ready (see tools/scenes/ for examples) --setup FILE.js code to run once the world is ready (see tools/scenes/ for examples)
@@ -51,6 +52,7 @@ def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('out') ap.add_argument('out')
ap.add_argument('--look', choices=['pixel', 'smooth'], default='pixel') ap.add_argument('--look', choices=['pixel', 'smooth'], default='pixel')
ap.add_argument('--renderer', choices=['webgpu', 'legacy'], default='webgpu')
ap.add_argument('--seed', type=int, default=424242) ap.add_argument('--seed', type=int, default=424242)
ap.add_argument('--size', default='2048x1024') ap.add_argument('--size', default='2048x1024')
ap.add_argument('--setup') ap.add_argument('--setup')
@@ -75,6 +77,8 @@ def main():
pg.add_init_script(FREEZE if a.freeze else '') pg.add_init_script(FREEZE if a.freeze else '')
pg.goto(url) pg.goto(url)
pg.evaluate("SET.fog = false") pg.evaluate("SET.fog = false")
pg.evaluate('CAPS_CHECK')
pg.evaluate('(r) => { SET.renderer = r; if (r === \'webgpu\' && !GPU.ok) throw Error(GPU.error || \'WebGPU unavailable\'); }', a.renderer)
pg.wait_for_timeout(300) pg.wait_for_timeout(300)
# pause the moment the world exists, so the shot depends only on the seed, the setup # pause the moment the world exists, so the shot depends only on the seed, the setup
# code and --ticks (simTick is deterministic), not on how fast the browser ran # code and --ticks (simTick is deterministic), not on how fast the browser ran
@@ -99,6 +103,8 @@ def main():
pg.evaluate("() => { window.__frozenNow = 5000; window.requestAnimationFrame = () => 0; }") pg.evaluate("() => { window.__frozenNow = 5000; window.requestAnimationFrame = () => 0; }")
pg.wait_for_timeout(100) pg.wait_for_timeout(100)
pg.evaluate("() => { R.frame = 999; if (typeof SM !== 'undefined') SM.t0 = 0; frame(lastRender + 1000); }") pg.evaluate("() => { R.frame = 999; if (typeof SM !== 'undefined') SM.t0 = 0; frame(lastRender + 1000); }")
if a.look == 'pixel' and a.renderer == 'webgpu' and not pg.evaluate('R.gpuOn'):
raise RuntimeError('WebGPU fell back: ' + pg.evaluate('GPU.error'))
kw = {} kw = {}
if a.clip: if a.clip:
x, y, w, h = (float(v) for v in a.clip.split(',')) x, y, w, h = (float(v) for v in a.clip.split(','))
@@ -107,7 +113,7 @@ def main():
pg.screenshot(path=a.out, **kw) pg.screenshot(path=a.out, **kw)
note = pg.evaluate("SET.look === 'smooth' ? (SM.ok ? 'smooth look' : 'smooth look FAILED: ' + SM.why) : 'pixel look'") note = pg.evaluate("SET.look === 'smooth' ? (SM.ok ? 'smooth look' : 'smooth look FAILED: ' + SM.why) : 'pixel look'")
b.close() b.close()
print(f'saved {a.out} ({note})') print(f"saved {a.out} ({note}, {a.renderer if a.look == 'pixel' else 'legacy'})")
for e in errors: for e in errors:
print('ERROR', e) print('ERROR', e)
sys.exit(1 if errors else 0) sys.exit(1 if errors else 0)
+3 -2
View File
@@ -38,6 +38,7 @@ def main():
pg.on('pageerror', lambda e: errors.append(str(e))) pg.on('pageerror', lambda e: errors.append(str(e)))
pg.add_init_script('window.requestAnimationFrame = () => 0;') pg.add_init_script('window.requestAnimationFrame = () => 0;')
pg.goto(url) pg.goto(url)
pg.evaluate('CAPS_CHECK')
pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text()) pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text())
assert pg.evaluate('crossOriginIsolated') assert pg.evaluate('crossOriginIsolated')
# Compare to the unwrapped functions, and hash the byte representation of all grid state. # Compare to the unwrapped functions, and hash the byte representation of all grid state.
@@ -66,8 +67,8 @@ def main():
performance.now = () => 5000; performance.now = () => 5000;
R.rain = rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + R.bw / 2)))); R.rain = rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + R.bw / 2))));
try { try {
PERF_MARKS = false; drawWorld(); const a = R.canvas.toDataURL(); PERF_MARKS = false; drawWorld(); const a = worldPixelContext().canvas.toDataURL();
PERF_MARKS = true; drawWorld(); const b = R.canvas.toDataURL(); PERF_MARKS = true; drawWorld(); const b = worldPixelContext().canvas.toDataURL();
return a === b; return a === b;
} finally { PERF_MARKS = false; performance.now = now; } } finally { PERF_MARKS = false; performance.now = now; }
}''', look) }''', look)