WebGPU renderer complete: dirty uploads, default limits, legacy removed, WebGPU required, warm-up (t88 phase 2d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
A falling-sand factory game in the browser, in the style of Sandustry.
|
A falling-sand factory game in the browser, in the style of Sandustry.
|
||||||
|
|
||||||
|
WebGPU is required (Chrome or Edge with a supported graphics adapter). Both looks use WebGPU;
|
||||||
|
there is no fallback renderer. Browsers without an adapter, including Firefox on Linux in our
|
||||||
|
checks, show a notice on the start screen. Serve the page over HTTP as described below.
|
||||||
|
|
||||||
Every grain of sand, ore, water and fire is simulated. You fly a small bot over a side-view
|
Every grain of sand, ore, water and fire is simulated. You fly a small bot over a side-view
|
||||||
world, dig with a resonance drill and vacuum up what breaks loose. You build furnaces from
|
world, dig with a resonance drill and vacuum up what breaks loose. You build furnaces from
|
||||||
blocks, melt ore with coal and air, and cast the metal in molds. Then you sell bars, alloys and
|
blocks, melt ore with coal and air, and cast the metal in molds. Then you sell bars, alloys and
|
||||||
@@ -51,7 +55,8 @@ The controls are in the in-game notebook (N) under Controls. The basics:
|
|||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots:
|
You need Node (tested with 22), Python 3, and Playwright for the browser tests and screenshots.
|
||||||
|
The golden-image tests also use ImageMagick (`magick`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv venv .venv
|
uv venv .venv
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ src/manifest.json the build order: simulation files, then browser files
|
|||||||
src/shell.html page markup (HUD, menus, dialogs); CSS and JS are put in at the markers
|
src/shell.html page markup (HUD, menus, dialogs); CSS and JS are put in at the markers
|
||||||
src/styles/ base styles and the dev console panel
|
src/styles/ base styles and the dev console panel
|
||||||
src/sim/ the simulation. No DOM. The headless tests load only these files.
|
src/sim/ the simulation. No DOM. The headless tests load only these files.
|
||||||
src/render/ drawing: the pixel look (CPU) and the smooth look (WebGL2), overlays
|
src/render/ drawing: WebGPU pixel and smooth looks, rare CPU overrides and overlays
|
||||||
src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves,
|
src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves,
|
||||||
audio, the frame loop
|
audio, the frame loop
|
||||||
test/ headless suites (t_*.js) and their harness; browser tests (play.py,
|
test/ headless suites (t_*.js) and their harness; browser tests (play.py,
|
||||||
@@ -149,23 +149,19 @@ The frame loop applies speed and queued steps while keeping the per-frame tick l
|
|||||||
|
|
||||||
## Drawing
|
## Drawing
|
||||||
|
|
||||||
`SET.renderer` selects WebGPU or Legacy for either look. `src/render/cell.js` holds the shared
|
Both looks require WebGPU. `cell.js` colours rare built geometry and `objects.js` adds bars,
|
||||||
CPU rules; `objects.js` adds bars, flyers and blueprint ghosts. The GPU canvas sits behind the
|
flyers and blueprint ghosts. The GPU canvas sits behind the transparent overlay canvas.
|
||||||
transparent game canvas, which draws the existing overlays (machines, molds, bot, rain, arrows).
|
|
||||||
|
|
||||||
- **WebGPU** (`src/render/gpu/`): `textures.js` uploads visible world rectangles, `colour.js`
|
- `gpu/device.js` compiles every pipeline asynchronously at startup, submits both looks during
|
||||||
colours plain grains, and `draw.js` selects rare CPU override blocks and submits the passes.
|
world loading, and handles one device recreation followed by an autosaved Reload notice.
|
||||||
`light.js` computes lighting; the CPU `computeLight` is never called by this path.
|
- `gpu/textures.js` owns world/view textures and bindings; `gpu/uploads.js` merges dirty 32x32
|
||||||
- Pixel uses nearest upscaling and separable GPU bloom (`shaders.js`).
|
chunks into horizontal strips, retaining clean chunks across camera moves. Simulation writes
|
||||||
- Smooth adds class, back colour/sky flag, tree/geometry tags and occupancy field outputs to
|
mark `renderDirty` in `sim/world.js`; hot chunks include the conduction halo.
|
||||||
colour compute. Only this look uploads the CPU back/tree/field overrides and presence mask.
|
- `gpu/colour.js` colours plain grains. Smooth uses a second pass for its four outputs so no
|
||||||
- `smooth-shader.js` and `smooth-slope.js` literally port the legacy GLSL shapes to WGSL.
|
pipeline exceeds four storage textures. `gpu/cracks.js` supplies drill contour shading.
|
||||||
`smooth.js` creates the smooth pipeline, field mip passes and bindings. Field AO samples
|
- `gpu/light.js` computes sky, heat emission, lamp and light propagation. No CPU light sweep.
|
||||||
level 2.2; glow retains the legacy nine-tap filter. WebGPU smooth always draws full resolution.
|
- `gpu/shaders.js` provides pixel upscaling and bloom. `gpu/smooth*.js` shapes smooth grains,
|
||||||
- **Legacy**: `drawWorld` fills every cell on the CPU, and `render/light.js` computes lighting.
|
field AO and glow; `gpu/surface.js` caches the parallax horizon.
|
||||||
Pixel uses Canvas2D; smooth (`render/smooth.js`, `smooth-shaders.js`, `slope-belt-shader.js`)
|
|
||||||
shades the four inputs with WebGL2 and keeps its adaptive resolution/fallback. Both smooth
|
|
||||||
renderers share `smoothSurface` for the cached parallax horizon.
|
|
||||||
- Smooth classes are set in `cell.js`: 4 crisp built piece, 6 wall tile, 7/8 belt, 9–12 screw,
|
- Smooth classes are set in `cell.js`: 4 crisp built piece, 6 wall tile, 7/8 belt, 9–12 screw,
|
||||||
13/14 filter, 19–22 diagonal screw, 23–30 slope belt/filter, 31/32 slope sidewall and cleats.
|
13/14 filter, 19–22 diagonal screw, 23–30 slope belt/filter, 31/32 slope sidewall and cleats.
|
||||||
`diagonal-screws.js` and `slope-belts.js` share the simulation join geometry with pixel colours,
|
`diagonal-screws.js` and `slope-belts.js` share the simulation join geometry with pixel colours,
|
||||||
@@ -248,22 +244,22 @@ Starter furnace designs and the milestone list shown in the notebook.
|
|||||||
- functions: smelterBlueprint
|
- functions: smelterBlueprint
|
||||||
- variables: STARTER_DESIGNS, MILESTONES
|
- variables: STARTER_DESIGNS, MILESTONES
|
||||||
|
|
||||||
### src/sim/world.js (107 lines)
|
### src/sim/world.js (113 lines)
|
||||||
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
|
World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
|
||||||
- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
|
- functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, renderDirtyXY, renderDirtyIdx, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx
|
||||||
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
|
- variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, renderDirty, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats
|
||||||
|
|
||||||
### src/sim/fog.js (52 lines)
|
### src/sim/fog.js (52 lines)
|
||||||
Exploration only: never changes grains or the random stream.
|
Exploration only: never changes grains or the random stream.
|
||||||
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
|
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
|
||||||
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
|
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH
|
||||||
|
|
||||||
### src/sim/cells.js (225 lines)
|
### src/sim/cells.js (227 lines)
|
||||||
Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove).
|
Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove).
|
||||||
- functions: setCell, swapCells, move, enter, enterFree, powder, passable, liquid, gas, wakeBotPowder, simMove
|
- functions: setCell, swapCells, move, enter, enterFree, powder, passable, liquid, gas, wakeBotPowder, simMove
|
||||||
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
|
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
|
||||||
|
|
||||||
### src/sim/heat.js (266 lines)
|
### src/sim/heat.js (271 lines)
|
||||||
Heat: conduction, melting and freezing, reactions, fire and air, steel.
|
Heat: conduction, melting and freezing, reactions, fire and air, steel.
|
||||||
- functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck
|
- functions: curAir, airflowAt, alloyMix, airy, hasO2, condPair, heatPass, heatChunk, neighborDir, react, char, quench, burningNear, burn, steelCheck
|
||||||
- variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY
|
- variables: POROUS, SOIL, HOTLIQ, REACT_MIN, ALLOY
|
||||||
@@ -297,7 +293,7 @@ Travel-directed transport ends. Precomputed probes; world/plan queries allocate
|
|||||||
- functions: pieceEnds, joinCandidate, endJoin, pieceJoins, endAt, joinIsDrop, joinSnap, nearMiss
|
- functions: pieceEnds, joinCandidate, endJoin, pieceJoins, endAt, joinIsDrop, joinSnap, nearMiss
|
||||||
- variables: JOIN_ENDS, NO_PIECE
|
- variables: JOIN_ENDS, NO_PIECE
|
||||||
|
|
||||||
### src/sim/conveyors.js (212 lines)
|
### src/sim/conveyors.js (213 lines)
|
||||||
How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full.
|
How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full.
|
||||||
- functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, screwAccepts, straightScrewNext, diagonalScrewNext
|
- functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, screwAccepts, straightScrewNext, diagonalScrewNext
|
||||||
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty
|
- variables: CARRY_DEPTH, filterR, filterL, filtersDirty
|
||||||
@@ -312,7 +308,7 @@ Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SP
|
|||||||
- functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad
|
- functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad
|
||||||
- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog
|
- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog
|
||||||
|
|
||||||
### src/sim/blocks.js (235 lines)
|
### src/sim/blocks.js (237 lines)
|
||||||
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
|
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
|
||||||
- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks
|
- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks
|
||||||
|
|
||||||
@@ -336,7 +332,7 @@ The resonance drill: tuned pulses that shatter one material.
|
|||||||
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing
|
- functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick, pingRadius, resonancePing
|
||||||
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS
|
- variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF, PING_VEINS
|
||||||
|
|
||||||
### src/sim/nature.js (168 lines)
|
### src/sim/nature.js (170 lines)
|
||||||
Trees (background layer), acorns, saplings, and waking freezing water.
|
Trees (background layer), acorns, saplings, and waking freezing water.
|
||||||
- functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
|
- functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
|
||||||
- variables: treeBases, saplings, treeDraw
|
- variables: treeBases, saplings, treeDraw
|
||||||
@@ -396,15 +392,11 @@ Saving and loading a world (serializeWorld / deserializeWorld) and old-save migr
|
|||||||
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
|
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
|
||||||
- variables: SAVE_VERSION, TOFF
|
- variables: SAVE_VERSION, TOFF
|
||||||
|
|
||||||
### src/render/setup.js (200 lines)
|
### src/render/setup.js (198 lines)
|
||||||
Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera.
|
Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera.
|
||||||
- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont
|
- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont
|
||||||
- variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B
|
- variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B
|
||||||
|
|
||||||
### src/render/light.js (103 lines)
|
|
||||||
Propagated coloured light from glowing and burning cells.
|
|
||||||
- functions: builtForLight, looseForLight, computeLight
|
|
||||||
|
|
||||||
### src/render/slope-belts.js (167 lines)
|
### src/render/slope-belts.js (167 lines)
|
||||||
Drawing only: the face stays on its simulation cells; the band hangs below it.
|
Drawing only: the face stays on its simulation cells; the band hangs below it.
|
||||||
- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat
|
- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat
|
||||||
@@ -415,14 +407,19 @@ 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/cell.js (272 lines)
|
### src/render/cell.js (276 lines)
|
||||||
Shared CPU colour rules, used by legacy and the rare-cell WebGPU override.
|
Rare-cell shading also treats built pieces as transparent surroundings.
|
||||||
- functions: cellPixel
|
- functions: builtForShade, cellPixel
|
||||||
|
|
||||||
### src/render/objects.js (80 lines)
|
### src/render/objects.js (80 lines)
|
||||||
Loose bars, flying grains and queued blueprint blocks in the grain buffer.
|
Loose bars, flying grains and queued blueprint blocks in the grain buffer.
|
||||||
- functions: worldObjects
|
- functions: worldObjects
|
||||||
|
|
||||||
|
### src/render/gpu/cracks.js (79 lines)
|
||||||
|
The drill's three seeded Perlin contour fields, uploaded once with the material tables.
|
||||||
|
- functions: gpuCrackTables
|
||||||
|
- variables: GPU_CRACKS, GPU_CRACK_COLOUR, GPU_CRACK_DRAW
|
||||||
|
|
||||||
### src/render/gpu/colour.js (101 lines)
|
### src/render/gpu/colour.js (101 lines)
|
||||||
The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
|
The plain-cell rules mirror cellPixel; built geometry stays in the CPU override.
|
||||||
- functions: gpuColourShader
|
- functions: gpuColourShader
|
||||||
@@ -445,26 +442,34 @@ Smooth slope geometry and cleats, ported from slope-belt-shader.js.
|
|||||||
Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
|
Literal port of the legacy smooth shader; dpdy is reversed for WebGPU's top-left origin.
|
||||||
- variables: GPU_SMOOTH
|
- variables: GPU_SMOOTH
|
||||||
|
|
||||||
### src/render/gpu/smooth.js (47 lines)
|
### src/render/gpu/surface.js (5 lines)
|
||||||
|
- functions: smoothSurface
|
||||||
|
- variables: SM
|
||||||
|
|
||||||
|
### src/render/gpu/smooth.js (48 lines)
|
||||||
Field mip levels preserve the legacy trilinear AO sample at level 2.2.
|
Field mip levels preserve the legacy trilinear AO sample at level 2.2.
|
||||||
- functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips
|
- functions: gpuSmoothInit, gpuSmoothTextures, gpuSmoothBindings, gpuSmoothUpload, gpuSmoothMips
|
||||||
- variables: GPU_FIELD_MIP
|
- variables: GPU_FIELD_MIP
|
||||||
|
|
||||||
### src/render/gpu/device.js (48 lines)
|
### src/render/gpu/device.js (89 lines)
|
||||||
Async startup shares the title screen's adapter; failures leave a usable legacy renderer.
|
All pipelines compile during startup; rendering never falls back.
|
||||||
- functions: gpuInit, gpuFail, worldPixelContext
|
- functions: gpuLimitsOK, gpuInit, gpuFail, gpuRecover, gpuWarmWorld, worldPixelContext
|
||||||
- variables: GPU
|
- variables: GPU_LIMITS, GPU
|
||||||
|
|
||||||
### src/render/gpu/textures.js (104 lines)
|
### src/render/gpu/textures.js (98 lines)
|
||||||
Whole-world allocation, direct strided uploads; no cell packing or dirty tracking.
|
Whole-world textures retain clean chunks across camera moves.
|
||||||
- functions: gpuTexture, gpuWrite, gpuBuffer, gpuWorldTextures, gpuViewTextures, gpuBindings, gpuUpload
|
- functions: gpuTexture, gpuWrite, gpuBuffer, gpuWorldTextures, gpuViewTextures, gpuBindings, gpuUpload
|
||||||
|
|
||||||
### src/render/gpu/draw.js (83 lines)
|
### src/render/gpu/uploads.js (48 lines)
|
||||||
|
Upload full chunks so scrolling within a resident chunk needs no extra copy.
|
||||||
|
- functions: gpuUploadChunks, gpuUploadStrip, gpuCheckUploads
|
||||||
|
|
||||||
|
### src/render/gpu/draw.js (92 lines)
|
||||||
Only exceptional blocks and their geometric neighbours run the CPU colour rules.
|
Only exceptional blocks and their geometric neighbours run the CPU colour rules.
|
||||||
- functions: gpuOverrideHas, gpuOverrides, gpuDraw, gpuPresent
|
- functions: gpuOverrideHas, gpuOverrides, gpuDraw, gpuPresent
|
||||||
- variables: GPU_PATTERN
|
- variables: GPU_PATTERN
|
||||||
|
|
||||||
### src/render/world.js (85 lines)
|
### src/render/world.js (46 lines)
|
||||||
Scale colour without changing the smooth-look class.
|
Scale colour without changing the smooth-look class.
|
||||||
- functions: fogColor, drawWorld
|
- functions: fogColor, drawWorld
|
||||||
|
|
||||||
@@ -477,19 +482,6 @@ Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, t
|
|||||||
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
|
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
|
||||||
- variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
|
- variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
|
||||||
|
|
||||||
### src/render/smooth.js (108 lines)
|
|
||||||
Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
|
|
||||||
- functions: smCompile, smoothAvailable, smUpload, smoothSurface, smoothDraw, smoothPace
|
|
||||||
- variables: SM, smBytes
|
|
||||||
|
|
||||||
### src/render/slope-belt-shader.js (111 lines)
|
|
||||||
Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
|
|
||||||
- variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW
|
|
||||||
|
|
||||||
### src/render/smooth-shaders.js (377 lines)
|
|
||||||
Smooth shaders: vertex and fragment programs for the rounded world look.
|
|
||||||
- variables: SM_VS, SM_FS
|
|
||||||
|
|
||||||
### src/ui/audio.js (230 lines)
|
### src/ui/audio.js (230 lines)
|
||||||
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
|
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
|
||||||
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
|
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
|
||||||
@@ -500,26 +492,26 @@ 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 (76 lines)
|
### src/ui/title.js (77 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_WEBGPU, REQUIRE_THREADS, CAPS, CAPS_CHECK, FONT5, TITLE
|
||||||
|
|
||||||
### src/ui/saves.js (224 lines)
|
### src/ui/saves.js (224 lines)
|
||||||
Saves in IndexedDB, world files (export / import) and the blueprint library.
|
Saves in IndexedDB, world files (export / import) and the blueprint library.
|
||||||
- functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
|
- functions: newWorldId, worldWrite, listWorlds, putWorldEntry, worldNewest, deleteWorld, cleanWorldName, renameWorld, migrateWorlds, saveWorld, newestSave, b64FromBytes, bytesFromB64, gz, packBuffers, unpackBuffers, exportWorld, importWorldFile, libLoad, libSave, bpEncode, bpValid, bpDecode
|
||||||
- variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount
|
- variables: DB, worldIdTime, worldIdCounter, worldWrites, savingCount
|
||||||
|
|
||||||
### src/ui/claims.js (145 lines)
|
### src/ui/claims.js (146 lines)
|
||||||
Shared player state; the world save remains a complete standalone fallback.
|
Shared player state; the world save remains a complete standalone fallback.
|
||||||
- functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab
|
- functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab
|
||||||
- variables: career, claimBusy
|
- variables: career, claimBusy
|
||||||
|
|
||||||
### src/ui/session.js (160 lines)
|
### src/ui/session.js (162 lines)
|
||||||
Starting, loading and quitting a game; modal dialogs; the pause menu.
|
Starting, loading and quitting a game; modal dialogs; the pause menu.
|
||||||
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
|
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
|
||||||
|
|
||||||
### src/ui/settings.js (126 lines)
|
### src/ui/settings.js (123 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
|
||||||
@@ -593,10 +585,9 @@ Gamepad input.
|
|||||||
Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
|
Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
|
||||||
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
|
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
|
||||||
|
|
||||||
### src/ui/preview-drill.js (107 lines)
|
### src/ui/preview-drill.js (67 lines)
|
||||||
Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines run on unbroken from one cell to the next and look like real fractures, not a pattern.
|
Drill pulse wavefronts and aiming fan. The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
|
||||||
- functions: crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim
|
- functions: pulseRGB, fanPoints, drawDrillPulses, drawDrillAim
|
||||||
- variables: CRACK_N, CRACK_LV
|
|
||||||
|
|
||||||
### src/ui/preview-selection.js (81 lines)
|
### src/ui/preview-selection.js (81 lines)
|
||||||
Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels)
|
Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small labelled tag drawn on the world (screen pixels)
|
||||||
@@ -616,7 +607,7 @@ Shared world colours, minimap and full map.
|
|||||||
Browser-only clocks; simulation code stays usable without a browser or a clock.
|
Browser-only clocks; simulation code stays usable without a browser or a clock.
|
||||||
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
|
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
|
||||||
|
|
||||||
### src/ui/loop.js (148 lines)
|
### src/ui/loop.js (143 lines)
|
||||||
The frame loop and boot.
|
The frame loop and boot.
|
||||||
- functions: scanVisible, frame, boot
|
- functions: scanVisible, frame, boot
|
||||||
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
|
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
|
||||||
@@ -627,12 +618,14 @@ The frame loop and boot.
|
|||||||
- `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent)
|
- `test/furnace_play.js`: Fires a starter furnace design repeatedly the way a player would, without cleaning it out between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode] mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent)
|
||||||
- `test/gpu-readback.js`: Read the four actual smooth textures without adding COPY_SRC usage to game resources.
|
- `test/gpu-readback.js`: Read the four actual smooth textures without adding COPY_SRC usage to game resources.
|
||||||
- `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
|
- `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
|
||||||
|
- `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness.
|
||||||
|
- `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass.
|
||||||
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
|
- `test/play.py`: Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue. Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the second argument is 'smooth'.
|
||||||
- `test/run_all.py`: Run the test suites and report.
|
- `test/run_all.py`: Run the test suites and report.
|
||||||
- `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports.
|
- `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports.
|
||||||
- `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
|
- `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
|
||||||
- `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
|
- `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
|
||||||
- `test/t_caps.py`: Capability detection, title warnings and the future startup gate.
|
- `test/t_caps.py`: Capability detection, title notices and the required-WebGPU startup gate.
|
||||||
- `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
|
- `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters.
|
||||||
- `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen.
|
- `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen.
|
||||||
- `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime.
|
- `test/t_cursor.js`: Ore identification, cached connected veins, ping timing, settings and label lifetime.
|
||||||
@@ -644,9 +637,10 @@ 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_gpu.py`: Dirty uploads, default limits, warm pipelines, world replacement and device recovery.
|
||||||
|
- `test/t_gpu_golden.py`: Frozen legacy references, captured before retirement; compare the WebGPU world only.
|
||||||
|
- `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid.
|
||||||
- `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables.
|
- `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables.
|
||||||
- `test/t_light.js`: Run the renderer's lighting against real simulation data, with only canvas storage stubbed.
|
|
||||||
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
|
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
|
||||||
- `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
|
- `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
|
||||||
- `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling.
|
- `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling.
|
||||||
@@ -654,12 +648,13 @@ The frame loop and boot.
|
|||||||
- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way.
|
- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way.
|
||||||
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
|
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
|
||||||
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
|
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
|
||||||
|
- `test/t_render_dirty.js`: Render invalidation must not depend on whether a grain moved or the chunk stayed awake.
|
||||||
- `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget.
|
- `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget.
|
||||||
- `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly.
|
- `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly.
|
||||||
- `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport.
|
- `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport.
|
||||||
- `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls.
|
- `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls.
|
||||||
- `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence.
|
- `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence.
|
||||||
- `test/t_smooth.py`: The smooth look: the WebGPU pass compiles, draws, falls back cleanly, and the thermal lens still works.
|
- `test/t_smooth.py`: The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works.
|
||||||
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
|
- `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders.
|
||||||
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
|
- `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds.
|
||||||
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
|
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
|
||||||
|
|||||||
@@ -67,30 +67,42 @@ diff -r /tmp/before /tmp/after
|
|||||||
gamepad and world sizes. Screenshots go to
|
gamepad and world sizes. Screenshots go to
|
||||||
`shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth
|
`shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth
|
||||||
look.
|
look.
|
||||||
- `test/t_smooth.py` checks that the WebGPU smooth look compiles and draws, compares legacy at half resolution, and falls back
|
- `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens.
|
||||||
to pixels cleanly, and that the thermal lens works.
|
- `test/t_gpu_golden.py` compares 28 cropped legacy reference PNGs (384x288, under
|
||||||
- Run them one at a time. Two headless browsers at once can starve each other, and
|
`test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes,
|
||||||
`t_smooth.py`'s half-resolution check then fails. `run_all.py` runs them one after the other.
|
both looks at zoom 12 and 2. ImageMagick decodes RGB; the per-channel tolerance is 8,
|
||||||
|
mean RGB error must stay below 0.5/255 and fewer than 0.3% of pixels may exceed 8
|
||||||
|
(smooth AO/edge rounding). These are frozen pre-removal legacy images, not generated
|
||||||
|
from the new renderer. Inspect the matching `shots/golden-*.png` after visible changes.
|
||||||
|
- `test/t_gpu.py` checks default limits (four storage textures per stage), dirty-upload
|
||||||
|
readback after 240 busy ticks with scrolling and both looks, exact dirty/full image equality
|
||||||
|
with a frozen clock, world replacement, transparent overlays, drill contours, shader compilation timing, successful device recreation and failed recovery with
|
||||||
|
an autosave and Reload. `gpuCheckUploads()` reads actual resident GPU texture bytes and
|
||||||
|
compares them to the full CPU arrays, including Float32 temperatures, machine and block data.
|
||||||
|
- `test/t_render_dirty.js` checks invalidation of sleeping chunks, heat edges, swaps, fog and trees.
|
||||||
|
- The old CPU light checks now run as `test/t_gpu_light.py` / `test/light-cases.js`, reading the
|
||||||
|
actual WGSL float light grid. Transmission tolerance is 0.001 because the GPU work textures
|
||||||
|
store float16 (the removed CPU implementation used float32). Behavioural expectations remain.
|
||||||
|
- Browser suites run one at a time; concurrent GPU work distorts timing and can starve tests.
|
||||||
- Rebuild first (`python3 build.py`); `run_all.py --browser` rebuilds for you. All browser tools
|
- Rebuild first (`python3 build.py`); `run_all.py --browser` rebuilds for you. All browser tools
|
||||||
use `tools/browser.py` and `tools/server.py`: a free-port background HTTP server serves the
|
use `tools/browser.py` and `tools/server.py`: a free-port background HTTP server serves the
|
||||||
built page with COOP/COEP headers and shuts down after the browser. No `file://` loads.
|
built page with COOP/COEP headers and shuts down after the browser. No `file://` loads.
|
||||||
- Choose Chromium graphics with `--gpu real|soft` or `DEEPGRAIN_GPU=real|soft` (the command line
|
- Choose Chromium graphics with `--gpu real|soft` or `DEEPGRAIN_GPU=real|soft` (the command line
|
||||||
wins). This works for the suite runner, individual browser tests, screenshots and profiler.
|
wins). This works for the suite runner, individual browser tests, screenshots and profiler.
|
||||||
Real mode uses ANGLE Vulkan on the host GPU; soft uses SwiftShader and sets
|
Real mode uses ANGLE Vulkan on the host GPU; soft uses SwiftShader.
|
||||||
`window.__allowSoftwareGL` before load. The explicit software-fallback test still uses soft
|
Unsupported software adapters show the same startup notice. Firefox uses its own default graphics backend.
|
||||||
mode with that override off. Firefox uses its own default graphics backend.
|
|
||||||
The default is **real**, including the profiler and `caps.py`. No colour tolerances were
|
The default is **real**, including the profiler and `caps.py`. No colour tolerances were
|
||||||
changed. The play test holds the slow-save fixture open during its screenshot, waits for
|
changed. The play test holds the slow-save fixture open during its screenshot, waits for
|
||||||
tool/selection UI updates, disables camera lead after testing it, and gives the gamepad drill
|
tool/selection UI updates, disables camera lead after testing it, and gives the gamepad drill
|
||||||
a known reachable target; these checks do not depend on GPU speed or earlier digging.
|
a known reachable target; these checks do not depend on GPU speed or earlier digging.
|
||||||
- `python3 tools/caps.py` prints isolation, actual WebGPU adapter info and the GL renderer for
|
- `python3 tools/caps.py` prints isolation, actual WebGPU adapter info and the GL renderer for
|
||||||
Chromium and installed Firefox. Real mode requires a hardware Chromium adapter.
|
Chromium and installed Firefox. Real mode requires a hardware Chromium adapter.
|
||||||
- `test/t_caps.py` checks missing, null and rejected WebGPU adapters, both title notices, warning
|
- `test/t_caps.py` checks absent, null, rejected and insufficient-limit adapters, notices and
|
||||||
mode and the future `REQUIRE_GPU_AND_THREADS` startup gate. Its forced notice screenshot is
|
the mandatory WebGPU startup gate. Threads remain optional until Phase 3. Firefox smoke
|
||||||
`shots/t88-p1-notice.png`.
|
checks that a browser without WebGPU shows the notice and cannot start a world.
|
||||||
- Install Firefox with `.venv/bin/python -m playwright install firefox`.
|
- Install Firefox with `.venv/bin/python -m playwright install firefox`.
|
||||||
`python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world,
|
`python3 test/firefox_smoke.py` checks isolation and the WebGPU notice, starts a pixel world
|
||||||
runs three seconds and checks page errors. `run_all.py --browser` includes it and prints a
|
only when WebGPU is available, and otherwise verifies the startup block. `run_all.py --browser` includes it and prints a
|
||||||
skip note if Firefox is absent; an installed browser that fails to launch is a failure.
|
skip note if Firefox is absent; an installed browser that fails to launch is a failure.
|
||||||
- Linux WebGPU currently needs Firefox Nightly; the notice points to `about:config` →
|
- Linux WebGPU currently needs Firefox Nightly; the notice points to `about:config` →
|
||||||
`dom.webgpu.enabled`. Sources: [Mozilla's experimental features](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features#webgpu_api)
|
`dom.webgpu.enabled`. Sources: [Mozilla's experimental features](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Experimental_features#webgpu_api)
|
||||||
@@ -245,3 +257,11 @@ The screenshot tool invalidates the shared parallax horizon after scene setup ch
|
|||||||
so renderer startup timing cannot change the hills in a comparison. Mip pass references:
|
so renderer startup timing cannot change the hills in a comparison. Mip pass references:
|
||||||
[Chrome's porting guide](https://developer.chrome.com/docs/web-platform/webgpu/from-webgl-to-webgpu#mipmap_generation)
|
[Chrome's porting guide](https://developer.chrome.com/docs/web-platform/webgpu/from-webgl-to-webgpu#mipmap_generation)
|
||||||
and [WebGPU Fundamentals](https://webgpufundamentals.org/webgpu/lessons/webgpu-importing-textures.html#generating-mips-on-the-gpu).
|
and [WebGPU Fundamentals](https://webgpufundamentals.org/webgpu/lessons/webgpu-importing-textures.html#generating-mips-on-the-gpu).
|
||||||
|
|
||||||
|
The required pipeline limits are checked in `gpuLimitsOK` before requesting the device: four
|
||||||
|
storage textures, sixteen sampled textures and eight storage buffers per stage, plus 8192 for
|
||||||
|
2D textures; no non-default limit is requested. References: [WebGPU limits](https://www.w3.org/TR/2026/CRD-webgpu-20260109/#limits)
|
||||||
|
and [Chromium Dawn defaults](https://dawn.googlesource.com/dawn.git/+/refs/heads/chromium/7945/src/dawn/native/Limits.cpp).
|
||||||
|
All game pipelines use async creation during capability checking. World loading submits both
|
||||||
|
looks before `GAME.running` becomes true. Shader and loading timings are printed by `t_gpu.py`;
|
||||||
|
look/zoom switches must not call synchronous pipeline creation.
|
||||||
|
|||||||
@@ -4,6 +4,59 @@ 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 2d, required WebGPU and dirty uploads (Codex)
|
||||||
|
|
||||||
|
- Per-32x32-chunk `renderDirty` marks cell writes, heat with its conduction halo, stationary
|
||||||
|
fire life, fog, drill damage, tree growth, eviction and block edits. `gpu/uploads.js` merges
|
||||||
|
horizontal dirty chunks into strips; clean resident chunks survive scrolling. GPU byte
|
||||||
|
readback matches the full CPU data after 240 busy ticks; an unchanged frame uploads no world cells.
|
||||||
|
- Both looks stay within default WebGPU limits: four storage textures, sixteen sampled textures,
|
||||||
|
eight storage buffers per shader stage, 8192 texture dimension; timestamp queries optional.
|
||||||
|
Smooth colour/geometry and glow use two passes. All pipelines compile asynchronously at startup;
|
||||||
|
both looks submit behind the loading screen. No fallback: missing/insufficient adapters cannot
|
||||||
|
start. Loss tries one device recreation; failure stops frames, autosaves, offers Reload (or
|
||||||
|
Export if saving fails). Firefox 157 on Linux shows the required-WebGPU notice.
|
||||||
|
- Removed `render/light.js`, `smooth.js`, `smooth-shaders.js`, `slope-belt-shader.js`, the CPU
|
||||||
|
plain-colour loop, renderer setting/tool switch and Canvas2D `drawCracks`. The rare CPU shade
|
||||||
|
classifier remains in `cell.js`; no CPU light sweep. Drill contours now use GPU colour shading
|
||||||
|
from `digp` and the same three seeded Perlin fields at screen resolution.
|
||||||
|
- Before removal captured 28 legacy reference crops (renderer, joins, sky, lamp/opening/magma/fire;
|
||||||
|
both looks, zoom 12/2), 384x288 PNGs under `test/fixtures/golden/` (1.7 MB total). All pass:
|
||||||
|
mean RGB error 0.012-0.287/255; <=0.15% pixels over 8. Golden test allows mean <0.5 and <0.3%
|
||||||
|
pixels over 8 for smooth AO rounding. Both contact sheets and pixel/smooth drill screenshots
|
||||||
|
inspected. The zoom-12 joins crop was recentered on actual geometry using the original renderer
|
||||||
|
built in a temporary checkout; the four joins crops were recaptured there.
|
||||||
|
- Final full matrix (1920x1080, RTX 5070), drawWorld zoom 2/6 (ms; baseline -> final):
|
||||||
|
settled pixel 21.78/2.71 -> 2.64/0.44, smooth 25.64/3.08 -> 5.16/0.71;
|
||||||
|
factory pixel 24.55/2.89 -> 2.53/0.47, smooth 28.20/3.34 -> 3.93/0.85;
|
||||||
|
heat pixel 24.35/3.41 -> 2.19/0.34, smooth 27.43/3.54 -> 3.51/0.85.
|
||||||
|
Factory uploads zoom 2/6: pixel 6.55/1.76 -> 0.53/0.09 ms, smooth 8.05/1.92 -> 1.88/0.44 ms
|
||||||
|
(fresh before/after runs). Real-loop CPU p50/p95 zoom 2/6: pixel 9.28/10.56 and 6.63/7.74 ms;
|
||||||
|
smooth 10.60/12.03 and 6.66/7.65 ms. Zoom 2 CPU p50 baseline pixel/smooth 31.70/34.71 ms.
|
||||||
|
Standalone sim is a remaining regression vs the old baseline: factory 3.66 -> 4.20 ms/tick,
|
||||||
|
heat 4.94 -> 5.65; settled 3.71 -> 3.49. No simulation behaviour changed (output diff exact).
|
||||||
|
Full default matrix and comparison: `/tmp/t88-final.json`, `/tmp/t88-final.log` (222.5 s).
|
||||||
|
- Isolated 960x600 warm-up check: async pipeline compilation 125.55 ms at startup; world warm-up
|
||||||
|
23.73 ms while loading; first visible frame 4.87 ms, following frames 2.10-12.47 ms (including
|
||||||
|
normal scheduling/GC variation). Look/zoom timings are in `/tmp/t88-gpu-final.log`; no pipeline
|
||||||
|
creation occurs on switches. First switch to smooth at zoom 12: 3.42 ms (normal max 3.51);
|
||||||
|
first zoom 2 smooth: 6.01 ms (1.85 ms above the normal max of 4.16); zoom 6 smooth
|
||||||
|
3.74 ms (normal max 3.38). All below a 16.67 ms frame budget; no first-use shader compilation.
|
||||||
|
These timings include waiting for submitted GPU work.
|
||||||
|
- Files: `sim/{world,cells,heat,fog,drill,tools,nature,machines,blocks,actions,conveyors}.js`,
|
||||||
|
`render/{world,cell,setup}.js`, `render/gpu/{uploads,cracks,surface,colour,device,draw,smooth,textures}.js`,
|
||||||
|
`ui/{title,session,claims,state,settings,loop,perf,preview-drill}.js`, manifest; browser tests,
|
||||||
|
`test/{light-cases,light-readback,t_render_dirty}.js`, golden fixtures; profiler/shot tools,
|
||||||
|
`tools/browser.py`, README, TESTING, PROJECT_MAP, t88 record. Old `test/t_light.js` moved to actual GPU checks,
|
||||||
|
`test/gpu-readback.js` retained for the play test. DESIGN and other tasks untouched.
|
||||||
|
- Verification: final `python3 test/run_all.py --browser` exited 0, ALL PASSED (361 play,
|
||||||
|
28 smooth, 18 console, 6 capabilities, 12 GPU, 28 golden, 71 light, 2 Firefox checks;
|
||||||
|
all headless suites, including nine render-dirty checks). Retained headless outputs match
|
||||||
|
before/after exactly (timing lines ignored). `tools/test_perf.py`, map generation, diff check
|
||||||
|
and kandown doctor passed. Early gate caught premature GAME.running during warm-up; fixed by
|
||||||
|
warming before enterGame. No commit. Phase 2 implementation complete; Phase 3 next. Owner
|
||||||
|
visual review pending; other GPU hardware and Firefox with an actual adapter not tested.
|
||||||
|
|
||||||
## 2026-10-27: t88 phase 2c, smooth look on WebGPU (Codex + Claude Code)
|
## 2026-10-27: t88 phase 2c, smooth look on WebGPU (Codex + Claude Code)
|
||||||
|
|
||||||
- WGSL port of the smooth look (`src/render/gpu/smooth.js`, `smooth-shader.js`, `smooth-slope.js`):
|
- WGSL port of the smooth look (`src/render/gpu/smooth.js`, `smooth-shader.js`, `smooth-slope.js`):
|
||||||
|
|||||||
@@ -45,26 +45,25 @@
|
|||||||
],
|
],
|
||||||
"client": [
|
"client": [
|
||||||
"render/setup.js",
|
"render/setup.js",
|
||||||
"render/light.js",
|
|
||||||
"render/slope-belts.js",
|
"render/slope-belts.js",
|
||||||
"render/diagonal-screws.js",
|
"render/diagonal-screws.js",
|
||||||
"render/cell.js",
|
"render/cell.js",
|
||||||
"render/objects.js",
|
"render/objects.js",
|
||||||
|
"render/gpu/cracks.js",
|
||||||
"render/gpu/colour.js",
|
"render/gpu/colour.js",
|
||||||
"render/gpu/shaders.js",
|
"render/gpu/shaders.js",
|
||||||
"render/gpu/light.js",
|
"render/gpu/light.js",
|
||||||
"render/gpu/smooth-slope.js",
|
"render/gpu/smooth-slope.js",
|
||||||
"render/gpu/smooth-shader.js",
|
"render/gpu/smooth-shader.js",
|
||||||
|
"render/gpu/surface.js",
|
||||||
"render/gpu/smooth.js",
|
"render/gpu/smooth.js",
|
||||||
"render/gpu/device.js",
|
"render/gpu/device.js",
|
||||||
"render/gpu/textures.js",
|
"render/gpu/textures.js",
|
||||||
|
"render/gpu/uploads.js",
|
||||||
"render/gpu/draw.js",
|
"render/gpu/draw.js",
|
||||||
"render/world.js",
|
"render/world.js",
|
||||||
"render/machines.js",
|
"render/machines.js",
|
||||||
"render/overlays.js",
|
"render/overlays.js",
|
||||||
"render/smooth.js",
|
|
||||||
"render/slope-belt-shader.js",
|
|
||||||
"render/smooth-shaders.js",
|
|
||||||
"ui/audio.js",
|
"ui/audio.js",
|
||||||
"ui/state.js",
|
"ui/state.js",
|
||||||
"ui/title.js",
|
"ui/title.js",
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
// Shared CPU colour rules, used by legacy and the rare-cell WebGPU override.
|
// Rare-cell shading also treats built pieces as transparent surroundings.
|
||||||
|
function builtForShade(i, x, y) {
|
||||||
|
return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i];
|
||||||
|
}
|
||||||
|
// CPU colour rules for the rare-cell WebGPU overrides.
|
||||||
function cellPixel(x, y) {
|
function cellPixel(x, y) {
|
||||||
const { bw, bh, px, vx0, vy0, smoothOn: sm, rain: rk } = R;
|
const { bw, bh, px, vx0, vy0, smoothOn: sm, rain: rk } = R;
|
||||||
const bx = x - vx0, by = y - vy0, row = by * bw;
|
const bx = x - vx0, by = y - vy0, row = by * bw;
|
||||||
@@ -35,7 +39,7 @@ function cellPixel(x, y) {
|
|||||||
const T = temp[i];
|
const T = temp[i];
|
||||||
// Also used by the thermal lens, which always draws in pixels.
|
// Also used by the thermal lens, which always draws in pixels.
|
||||||
let looseShade = 1;
|
let looseShade = 1;
|
||||||
if (!sm && m !== EMPTY && !builtForLight(i, x, y)) {
|
if (!sm && m !== EMPTY && !builtForShade(i, x, y)) {
|
||||||
if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065;
|
if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065;
|
||||||
else if (PH[m] === PH_SOLID && (
|
else if (PH[m] === PH_SOLID && (
|
||||||
(x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) ||
|
(x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) ||
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ ${smooth ? `@group(0) @binding(16) var backOut: texture_storage_2d<rgba8unorm, w
|
|||||||
@group(0) @binding(21) var fieldOver: texture_2d<f32>;
|
@group(0) @binding(21) var fieldOver: texture_2d<f32>;
|
||||||
@group(0) @binding(22) var maskOver: texture_2d<u32>;
|
@group(0) @binding(22) var maskOver: texture_2d<u32>;
|
||||||
` : ""}
|
` : ""}
|
||||||
@group(0) @binding(15) var glowT: texture_storage_2d<rgba8unorm, write>;
|
${smooth ? "" : "@group(0) @binding(15) var glowT: texture_storage_2d<rgba8unorm, write>;"}
|
||||||
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
|
fn phase(p: vec2i) -> i32 { return i32(lut[textureLoad(matT,p,0).r*3u+1u].x); }
|
||||||
fn powder(p: vec2i) -> bool {
|
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;
|
return all(p >= vec2i(0)) && all(p < vec2i(u.world.xy)) && phase(p)==${PH_POWDER} && textureLoad(partT,p,0).r==0u;
|
||||||
@@ -94,7 +94,7 @@ fn colour(@builtin(global_invocation_id) id: vec3u) {
|
|||||||
if (${smooth ? "textureLoad(maskOver,q,0).r>0u" : "over.a>0."}) { rgb=over.rgb; ${smooth ? "cl=over.a*255.; backValue=textureLoad(backOver,q,0); treeValue=textureLoad(treeOver,q,0); fieldValue=textureLoad(fieldOver,q,0);" : ""} bloom=max(bloom,textureLoad(overrideGlow,q/2,0).rgb); }
|
if (${smooth ? "textureLoad(maskOver,q,0).r>0u" : "over.a>0."}) { rgb=over.rgb; ${smooth ? "cl=over.a*255.; backValue=textureLoad(backOver,q,0); treeValue=textureLoad(treeOver,q,0); fieldValue=textureLoad(fieldOver,q,0);" : ""} bloom=max(bloom,textureLoad(overrideGlow,q/2,0).rgb); }
|
||||||
${smooth ? "textureStore(backOut,q,backValue); textureStore(treeOut,q,treeValue); textureStore(fieldOut,q,fieldValue);" : ""}
|
${smooth ? "textureStore(backOut,q,backValue); textureStore(treeOut,q,treeValue); textureStore(fieldOut,q,fieldValue);" : ""}
|
||||||
textureStore(outputT,q,vec4f(rgb,${smooth ? "cl/255." : "1"}));
|
textureStore(outputT,q,vec4f(rgb,${smooth ? "cl/255." : "1"}));
|
||||||
if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); }
|
${smooth ? "" : "if (((q.x | q.y) & 1)==0) { textureStore(glowT,q/2,vec4f(bloom,1)); }"}
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
const GPU_COLOUR = gpuColourShader(false);
|
const GPU_COLOUR = gpuColourShader(false);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// The drill's three seeded Perlin contour fields, uploaded once with the material tables.
|
||||||
|
function gpuCrackTables() {
|
||||||
|
const out = new Float32Array(3 * 512 * 4);
|
||||||
|
for (let l=0;l<3;l++) {
|
||||||
|
const rng=mulberry32(0xc7ac1+l), p=Array.from({length:256},(_,i)=>i);
|
||||||
|
for(let i=255;i>0;i--) { const j=Math.floor(rng()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
|
||||||
|
for(let i=0;i<512;i++) out[(l*512+i)*4]=p[i&255];
|
||||||
|
for(let i=0;i<256;i++) { const a=rng()*Math.PI*2; out[(l*512+i)*4+1]=Math.cos(a); out[(l*512+i)*4+2]=Math.sin(a); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const GPU_CRACKS = `
|
||||||
|
fn crackNoise(p: vec2f, l: u32) -> f32 {
|
||||||
|
let base=${NMAT*3+700+551*3}u+l*512u;
|
||||||
|
let v=vec2u(vec2i(floor(p)) & vec2i(255)); let f=fract(p);
|
||||||
|
let p0=u32(lut[base+v.y].x); let p1=u32(lut[base+v.y+1u].x);
|
||||||
|
let a=lut[base+u32(lut[base+v.x+p0].x)].yz;
|
||||||
|
let b=lut[base+u32(lut[base+v.x+1u+p0].x)].yz;
|
||||||
|
let c=lut[base+u32(lut[base+v.x+p1].x)].yz;
|
||||||
|
let d=lut[base+u32(lut[base+v.x+1u+p1].x)].yz;
|
||||||
|
let t=f*f*f*(f*(f*6.-15.)+10.);
|
||||||
|
return mix(mix(dot(a,f),dot(b,f-vec2f(1,0)),t.x),mix(dot(c,f-vec2f(0,1)),dot(d,f-vec2f(1)),t.x),t.y)*1.41;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
// Screen-resolution colour output preserves the old sub-grain marching-square strokes.
|
||||||
|
const GPU_CRACK_COLOUR = `
|
||||||
|
struct Params { view:vec4f, world:vec4f, style:vec4f, effects:vec4f, camera:vec4f, light:vec4f, screen:vec4f, extra:vec4f }
|
||||||
|
@group(0) @binding(0) var<uniform> u:Params;
|
||||||
|
@group(0) @binding(1) var<storage,read> lut:array<vec4f>;
|
||||||
|
@group(0) @binding(2) var matT:texture_2d<u32>;
|
||||||
|
@group(0) @binding(3) var digpT:texture_2d<u32>;
|
||||||
|
@group(0) @binding(4) var outputT:texture_storage_2d<rgba8unorm,write>;
|
||||||
|
${GPU_CRACKS}
|
||||||
|
fn segmentDistance(p:vec2f,a:vec2f,b:vec2f)->f32 {
|
||||||
|
let d=b-a;return length(p-a-d*clamp(dot(p-a,d)/max(0.000001,dot(d,d)),0.,1.));
|
||||||
|
}
|
||||||
|
@compute @workgroup_size(8,8)
|
||||||
|
fn main(@builtin(global_invocation_id) id:vec3u) {
|
||||||
|
let q=vec2i(id.xy);if(any(q>=vec2i(u.screen.xy))){return;}
|
||||||
|
let pos=u.extra.xy+(vec2f(q)+0.5)/u.camera.z; let p=vec2i(floor(pos));
|
||||||
|
var colour=vec4f(0);
|
||||||
|
if(u.camera.z>=2.5 && all(p>=vec2i(0)) && all(p<vec2i(u.world.xy))) {
|
||||||
|
let m=textureLoad(matT,p,0).r;let damage=f32(textureLoad(digpT,p,0).r)/255.;
|
||||||
|
if(lut[m*3u+1u].x==${PH_SOLID}.) {
|
||||||
|
for(var l=0u;l<3u;l++) {
|
||||||
|
if(damage<array<f32,3>(0.1,0.42,0.72)[l]){continue;}
|
||||||
|
let scale=array<f32,3>(1./7.,1./3.6,1./2.)[l];
|
||||||
|
let at=vec2f(p)*scale+vec2f(0.37,0.61);
|
||||||
|
let a=crackNoise(at,l);let b=crackNoise(at+vec2f(scale,0),l);
|
||||||
|
let c=crackNoise(at+vec2f(scale),l);let d=crackNoise(at+vec2f(0,scale),l);
|
||||||
|
var pts:array<vec2f,4>;var n=0u;
|
||||||
|
if((a<0.)!=(b<0.)){pts[n]=vec2f(a/(a-b),0);n++;}
|
||||||
|
if((b<0.)!=(c<0.)){pts[n]=vec2f(1,b/(b-c));n++;}
|
||||||
|
if((d<0.)!=(c<0.)){pts[n]=vec2f(d/(d-c),1);n++;}
|
||||||
|
if((a<0.)!=(d<0.)){pts[n]=vec2f(0,a/(a-d));n++;}
|
||||||
|
let width=array<f32,3>(0.2,0.14,0.09)[l];
|
||||||
|
for(var stroke=0u;stroke<2u;stroke++) {
|
||||||
|
let lip=stroke==0u;let offset=select(0.,max(1./u.camera.z,0.12),lip);
|
||||||
|
var distance=100.;
|
||||||
|
for(var k=0u;k+1u<n;k+=2u) {
|
||||||
|
distance=min(distance,segmentDistance(pos-vec2f(p)-vec2f(0,offset),pts[k],pts[k+1u]));
|
||||||
|
}
|
||||||
|
let w=max(1./u.camera.z,width*select(1.,1.3,lip))*0.5;
|
||||||
|
let alpha=clamp((w-distance)*u.camera.z+0.5,0.,1.)*select(0.78-f32(l)*0.12,0.13,lip);
|
||||||
|
let rgb=select(vec3f(18,12,10),vec3f(255,240,220),lip)/255.;
|
||||||
|
colour=vec4f(rgb*alpha+colour.rgb*(1.-alpha),alpha+colour.a*(1.-alpha));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textureStore(outputT,q,colour);
|
||||||
|
}`;
|
||||||
|
const GPU_CRACK_DRAW = `
|
||||||
|
@group(0) @binding(0) var cracks:texture_2d<f32>;
|
||||||
|
@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f {
|
||||||
|
let p=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3));return vec4f(p[i],0,1);
|
||||||
|
}
|
||||||
|
@fragment fn fs(@builtin(position) p:vec4f)->@location(0) vec4f {return textureLoad(cracks,vec2i(p.xy),0);}
|
||||||
|
`;
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
// Async startup shares the title screen's adapter; failures leave a usable legacy renderer.
|
// All pipelines compile during startup; rendering never falls back.
|
||||||
|
const GPU_LIMITS = { maxStorageTexturesPerShaderStage: 4, maxSampledTexturesPerShaderStage: 16, maxStorageBuffersPerShaderStage: 8, maxTextureDimension2D: 8192 };
|
||||||
|
function gpuLimitsOK(adapter) { return Object.entries(GPU_LIMITS).every(([k,v]) => adapter.limits[k] >= v); }
|
||||||
const GPU = { ok: false, canvas: document.getElementById('gpu'), error: '', gpuMs: null };
|
const GPU = { ok: false, canvas: document.getElementById('gpu'), error: '', gpuMs: null };
|
||||||
async function gpuInit(adapter) {
|
async function gpuInit(adapter) {
|
||||||
|
const started = performance.now();
|
||||||
try {
|
try {
|
||||||
|
if (!gpuLimitsOK(adapter)) throw Error("Graphics adapter does not meet the required WebGPU limits.");
|
||||||
const features = adapter.features.has('timestamp-query') ? ['timestamp-query'] : [];
|
const features = adapter.features.has('timestamp-query') ? ['timestamp-query'] : [];
|
||||||
const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features, requiredLimits: { maxStorageTexturesPerShaderStage: 5 } });
|
const d = GPU.device = await adapter.requestDevice({ requiredFeatures: features, requiredLimits: GPU_LIMITS });
|
||||||
d.addEventListener('uncapturederror', e => gpuFail(e.error.message));
|
d.addEventListener('uncapturederror', e => gpuFail(e.error.message));
|
||||||
d.lost.then(info => gpuFail(info.message || 'Graphics device lost'));
|
d.lost.then(info => { if (GPU.device === d) gpuRecover(info.message || 'Graphics device lost'); });
|
||||||
GPU.context = GPU.canvas.getContext('webgpu');
|
GPU.context = GPU.canvas.getContext('webgpu');
|
||||||
GPU.format = navigator.gpu.getPreferredCanvasFormat();
|
GPU.format = navigator.gpu.getPreferredCanvasFormat();
|
||||||
GPU.context.configure({ device: d, format: GPU.format, alphaMode: 'opaque' });
|
GPU.context.configure({ device: d, format: GPU.format, alphaMode: 'opaque' });
|
||||||
d.pushErrorScope('validation');
|
d.pushErrorScope('validation');
|
||||||
GPU.colour = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR }), entryPoint: 'colour' } });
|
GPU.colour = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR }), entryPoint: 'colour' } });
|
||||||
GPU.colourSmooth = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR_SMOOTH }), entryPoint: 'colour' } });
|
GPU.colourSmooth = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_COLOUR_SMOOTH }), entryPoint: 'colour' } });
|
||||||
gpuSmoothInit();
|
await gpuSmoothInit();
|
||||||
for (const [name, code, entryPoint] of [['lightRoof',GPU_LIGHT,'roof'],['lightSources',GPU_LIGHT,'sources'],['lightSpread',GPU_LIGHT_SPREAD,'main'],['lightPack',GPU_LIGHT_PACK,'main']]) {
|
for (const [name, code, entryPoint] of [['lightRoof',GPU_LIGHT,'roof'],['lightSources',GPU_LIGHT,'sources'],['lightSpread',GPU_LIGHT_SPREAD,'main'],['lightPack',GPU_LIGHT_PACK,'main']]) {
|
||||||
GPU[name] = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code}),entryPoint}});
|
GPU[name] = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code}),entryPoint}});
|
||||||
}
|
}
|
||||||
GPU.blur = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_BLUR }), entryPoint: 'main' } });
|
GPU.blur = await d.createComputePipelineAsync({ layout: 'auto', compute: { module: d.createShaderModule({ code: GPU_BLUR }), entryPoint: 'main' } });
|
||||||
GPU.draw = await d.createRenderPipelineAsync({ layout: 'auto', vertex: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'vs' }, fragment: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'fs', targets: [{ format: GPU.format }] }, primitive: { topology: 'triangle-list' } });
|
GPU.draw = await d.createRenderPipelineAsync({ layout: 'auto', vertex: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'vs' }, fragment: { module: d.createShaderModule({ code: GPU_DRAW }), entryPoint: 'fs', targets: [{ format: GPU.format }] }, primitive: { topology: 'triangle-list' } });
|
||||||
|
GPU.cracks = await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:GPU_CRACK_COLOUR}),entryPoint:'main'}});
|
||||||
|
const cracks = d.createShaderModule({code:GPU_CRACK_DRAW});
|
||||||
|
GPU.crackDraw = await d.createRenderPipelineAsync({layout:'auto',vertex:{module:cracks,entryPoint:'vs'},fragment:{module:cracks,entryPoint:'fs',targets:[{format:GPU.format,blend:{color:{srcFactor:'one',dstFactor:'one-minus-src-alpha'},alpha:{srcFactor:'one',dstFactor:'one-minus-src-alpha'}}}]},primitive:{topology:'triangle-list'}});
|
||||||
const error = await d.popErrorScope(); if (error) throw error;
|
const error = await d.popErrorScope(); if (error) throw error;
|
||||||
GPU.uniform = d.createBuffer({ size: 128, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
GPU.uniform = d.createBuffer({ size: 128, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||||
GPU.params = new Float32Array(32);
|
GPU.params = new Float32Array(32);
|
||||||
@@ -27,14 +34,48 @@ async function gpuInit(adapter) {
|
|||||||
GPU.resolve = d.createBuffer({ size: 256, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
|
GPU.resolve = d.createBuffer({ size: 256, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
|
||||||
GPU.readback = d.createBuffer({ size: 32, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
GPU.readback = d.createBuffer({ size: 32, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
||||||
}
|
}
|
||||||
GPU.ok = true;
|
GPU.ok = true; GPU.error = ''; GPU.initMs = performance.now() - started;
|
||||||
} catch (e) { gpuFail(e.message); }
|
return true;
|
||||||
|
} catch (e) { GPU.ok = false; GPU.error = e.message; return false; }
|
||||||
}
|
}
|
||||||
function gpuFail(message) {
|
function gpuFail(message) {
|
||||||
GPU.ok = false; GPU.error = message; SET.renderer = 'legacy';
|
GPU.ok = false; GPU.error = message;
|
||||||
GPU.canvas.hidden = true;
|
|
||||||
console.warn('WebGPU renderer: ' + message);
|
console.warn('WebGPU renderer: ' + message);
|
||||||
if (GAME.running) toast('WebGPU stopped drawing. Legacy renderer is on.');
|
if (GAME.running) gpuRecover(message);
|
||||||
|
}
|
||||||
|
async function gpuRecover(message) {
|
||||||
|
if (GPU.recovering || GPU.stopped) return;
|
||||||
|
GPU.ok = false; GPU.recovering = true;
|
||||||
|
const saved = !GAME.running || await saveWorld('auto', true);
|
||||||
|
const old = GPU.device;
|
||||||
|
// Resources belong to the lost device; force every retained cache to rebuild.
|
||||||
|
GPU.world = GPU.sky = GPU.viewKey = GPU.lightWorld = GPU.lightKey = null;
|
||||||
|
GPU.bindColour = GPU.lightDirections = GPU.queries = null;
|
||||||
|
GPU.timingBusy = false; GPU.lightFrame = undefined;
|
||||||
|
let restored = false;
|
||||||
|
try {
|
||||||
|
const adapter = navigator.gpu && await navigator.gpu.requestAdapter();
|
||||||
|
if (adapter) restored = await gpuInit(adapter);
|
||||||
|
} catch (e) { GPU.error = e.message; }
|
||||||
|
if (restored) { if (old) old.destroy(); GPU.recovering = false; lastT = performance.now(); acc = 0; return; }
|
||||||
|
GPU.device = null; if (old) old.destroy();
|
||||||
|
GPU.stopped = true; GPU.recovering = false; GAME.paused = true;
|
||||||
|
dialog('Graphics stopped', '<p>The graphics device could not restart. '+
|
||||||
|
(saved ? 'Your world was autosaved. Reload to continue.' : 'Saving failed. Export your world before reloading.')+'</p>', [
|
||||||
|
...(!saved ? [{label:'Export world',fn:exportWorld}] : []),
|
||||||
|
{label:'Reload',fn:()=>location.reload()}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// Submit both looks behind the loading screen, including their first real bindings.
|
||||||
|
async function gpuWarmWorld() {
|
||||||
|
buildSky(); resizeRender(); snapCamera();
|
||||||
|
const look = SET.look, started = performance.now();
|
||||||
|
try {
|
||||||
|
for (const next of [look === 'smooth' ? 'pixel' : 'smooth', look]) {
|
||||||
|
SET.look = next; drawWorld(); await GPU.device.queue.onSubmittedWorkDone();
|
||||||
|
}
|
||||||
|
GPU.warmMs = performance.now() - started;
|
||||||
|
} finally { SET.look = look; }
|
||||||
}
|
}
|
||||||
// Tests sample this composite so a transparent overlay cannot pass world-colour checks.
|
// Tests sample this composite so a transparent overlay cannot pass world-colour checks.
|
||||||
function worldPixelContext() {
|
function worldPixelContext() {
|
||||||
|
|||||||
@@ -48,8 +48,14 @@ function gpuDraw(lighting, glowOn, lightUpdate) {
|
|||||||
const blit = performance.now(), d = GPU.device, encoder = d.createCommandEncoder();
|
const blit = performance.now(), d = GPU.device, encoder = d.createCommandEncoder();
|
||||||
const timed = GPU.queries && !GPU.timingBusy && (PERF_MARKS || SET.showPerf);
|
const timed = GPU.queries && !GPU.timingBusy && (PERF_MARKS || SET.showPerf);
|
||||||
const compute = encoder.beginComputePass(timed ? { timestampWrites: { querySet: GPU.queries, beginningOfPassWriteIndex: 0 } } : {});
|
const compute = encoder.beginComputePass(timed ? { timestampWrites: { querySet: GPU.queries, beginningOfPassWriteIndex: 0 } } : {});
|
||||||
compute.setPipeline(R.smoothOn ? GPU.colourSmooth : GPU.colour); compute.setBindGroup(0, R.smoothOn ? GPU.bindColourSmooth : GPU.bindColour);
|
compute.setPipeline(GPU.colour); compute.setBindGroup(0, GPU.bindColour);
|
||||||
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8)); compute.end();
|
if (R.smoothOn) {
|
||||||
|
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
|
||||||
|
compute.setPipeline(GPU.colourSmooth); compute.setBindGroup(0, GPU.bindColourSmooth);
|
||||||
|
}
|
||||||
|
compute.dispatchWorkgroups(Math.ceil(R.bw / 8), Math.ceil(R.bh / 8));
|
||||||
|
compute.setPipeline(GPU.cracks); compute.setBindGroup(0, GPU.bindCracks);
|
||||||
|
compute.dispatchWorkgroups(Math.ceil(R.canvas.width / 8), Math.ceil(R.canvas.height / 8)); compute.end();
|
||||||
if (glowOn && !R.smoothOn) for (let i = 0; i < 2; i++) {
|
if (glowOn && !R.smoothOn) for (let i = 0; i < 2; i++) {
|
||||||
const pass = encoder.beginComputePass(); pass.setPipeline(GPU.blur); pass.setBindGroup(0, GPU.bindBlur[i]);
|
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();
|
pass.dispatchWorkgroups(Math.ceil(R.glowC.width / 8), Math.ceil(R.glowC.height / 8)); pass.end();
|
||||||
@@ -78,6 +84,9 @@ function gpuDraw(lighting, glowOn, lightUpdate) {
|
|||||||
// Also used for readback after the browser has expired the presented canvas texture.
|
// Also used for readback after the browser has expired the presented canvas texture.
|
||||||
function gpuPresent(encoder, timed = false) {
|
function gpuPresent(encoder, timed = false) {
|
||||||
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: GPU.context.getCurrentTexture().createView(), loadOp: 'clear', storeOp: 'store', clearValue: [0, 0, 0, 1] }],
|
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(R.smoothOn ? GPU.smooth : GPU.draw); pass.setBindGroup(0, R.smoothOn ? GPU.bindSmooth : GPU.bindDraw); pass.draw(3); pass.end();
|
pass.setPipeline(R.smoothOn ? GPU.smooth : GPU.draw); pass.setBindGroup(0, R.smoothOn ? GPU.bindSmooth : GPU.bindDraw); pass.draw(3); pass.end();
|
||||||
|
const cracks = encoder.beginRenderPass({colorAttachments:[{view:GPU.context.getCurrentTexture().createView(),loadOp:'load',storeOp:'store'}],
|
||||||
|
...(timed ? {timestampWrites:{querySet:GPU.queries,endOfPassWriteIndex:1}} : {})});
|
||||||
|
cracks.setPipeline(GPU.crackDraw);cracks.setBindGroup(0,GPU.bindCrackDraw);cracks.draw(3);cracks.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ const GPU_FIELD_MIP = `
|
|||||||
let size=max(vec2u(1),textureDimensions(source)/2u);
|
let size=max(vec2u(1),textureDimensions(source)/2u);
|
||||||
return textureSampleLevel(source,linear,p.xy/vec2f(size),0);
|
return textureSampleLevel(source,linear,p.xy/vec2f(size),0);
|
||||||
}`;
|
}`;
|
||||||
function gpuSmoothInit() {
|
async function gpuSmoothInit() {
|
||||||
const d=GPU.device;
|
const d=GPU.device;
|
||||||
const pipeline=(code,vertex,format)=>d.createRenderPipeline({layout:'auto',vertex:{module:d.createShaderModule({code:vertex}),entryPoint:'vs'},fragment:{module:d.createShaderModule({code}),entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}});
|
const pipeline=(code,vertex,format)=>d.createRenderPipelineAsync({layout:'auto',vertex:{module:d.createShaderModule({code:vertex}),entryPoint:'vs'},fragment:{module:d.createShaderModule({code}),entryPoint:'fs',targets:[{format}]},primitive:{topology:'triangle-list'}});
|
||||||
GPU.smooth=pipeline(GPU_SMOOTH,GPU_DRAW,GPU.format);
|
GPU.smooth=await pipeline(GPU_SMOOTH,GPU_DRAW,GPU.format);
|
||||||
GPU.fieldMip=pipeline(GPU_FIELD_MIP,GPU_FIELD_MIP,'rgba8unorm');
|
GPU.fieldMip=await pipeline(GPU_FIELD_MIP,GPU_FIELD_MIP,'rgba8unorm');
|
||||||
GPU.smoothUniform=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
|
GPU.smoothUniform=d.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
|
||||||
}
|
}
|
||||||
function gpuSmoothTextures() {
|
function gpuSmoothTextures() {
|
||||||
@@ -26,7 +26,8 @@ function gpuSmoothTextures() {
|
|||||||
GPU.viewTextures.push(...GPU.smoothOver,GPU.smoothMask,GPU.backTex,GPU.treeTex,GPU.fieldTex);
|
GPU.viewTextures.push(...GPU.smoothOver,GPU.smoothMask,GPU.backTex,GPU.treeTex,GPU.fieldTex);
|
||||||
}
|
}
|
||||||
function gpuSmoothBindings(group,view,buffer) {
|
function gpuSmoothBindings(group,view,buffer) {
|
||||||
GPU.bindColourSmooth=group(GPU.colourSmooth,[buffer(GPU.uniform),buffer(GPU.lut),buffer(GPU.land),...GPU.worldTextures.slice(0,9).map(view),view(GPU.override),view(GPU.overrideGlow),view(GPU.colourTex),view(GPU.glowTex),view(GPU.backTex),view(GPU.treeTex),GPU.fieldTex.createView({baseMipLevel:0,mipLevelCount:1}),...GPU.smoothOver.map(view),view(GPU.smoothMask)]);
|
const colourResources=[buffer(GPU.uniform),buffer(GPU.lut),buffer(GPU.land),...GPU.worldTextures.slice(0,9).map(view),view(GPU.override),view(GPU.overrideGlow),view(GPU.colourTex),null,view(GPU.backTex),view(GPU.treeTex),GPU.fieldTex.createView({baseMipLevel:0,mipLevelCount:1}),...GPU.smoothOver.map(view),view(GPU.smoothMask)];
|
||||||
|
GPU.bindColourSmooth=GPU.device.createBindGroup({layout:GPU.colourSmooth.getBindGroupLayout(0),entries:colourResources.map((resource,binding)=>({binding,resource})).filter(e=>e.resource)});
|
||||||
GPU.bindSmooth=group(GPU.smooth,[buffer(GPU.uniform),view(GPU.colourTex),view(GPU.backTex),view(GPU.treeTex),view(GPU.fieldTex),view(GPU.lightTex),view(GPU.glowTex),GPU.sampler,buffer(GPU.smoothUniform)]);
|
GPU.bindSmooth=group(GPU.smooth,[buffer(GPU.uniform),view(GPU.colourTex),view(GPU.backTex),view(GPU.treeTex),view(GPU.fieldTex),view(GPU.lightTex),view(GPU.glowTex),GPU.sampler,buffer(GPU.smoothUniform)]);
|
||||||
GPU.fieldMips=[];
|
GPU.fieldMips=[];
|
||||||
for(let i=1;i<GPU.fieldTex.mipLevelCount;i++) GPU.fieldMips.push({
|
for(let i=1;i<GPU.fieldTex.mipLevelCount;i++) GPU.fieldMips.push({
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const SM = { t0: performance.now(), surf: -1 };
|
||||||
|
function smoothSurface() {
|
||||||
|
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; }
|
||||||
|
return SM.surf;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Whole-world allocation, direct strided uploads; no cell packing or dirty tracking.
|
// Whole-world textures retain clean chunks across camera moves.
|
||||||
function gpuTexture(w, h, format = 'rgba8unorm', storage = false) {
|
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) });
|
return GPU.device.createTexture({ size: [w, h], format, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | (storage ? GPUTextureUsage.STORAGE_BINDING : 0) });
|
||||||
}
|
}
|
||||||
function gpuWrite(texture, data, stride, x, y, w, h) {
|
function gpuWrite(texture, data, stride, x, y, w, h) {
|
||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
@@ -16,15 +16,17 @@ function gpuWorldTextures() {
|
|||||||
if (GPU.worldTextures) for (const t of GPU.worldTextures) t.destroy();
|
if (GPU.worldTextures) for (const t of GPU.worldTextures) t.destroy();
|
||||||
if (GPU.lut) GPU.lut.destroy(); if (GPU.land) GPU.land.destroy();
|
if (GPU.lut) GPU.lut.destroy(); if (GPU.land) GPU.land.destroy();
|
||||||
GPU.world = mat; GPU.sky = SKY_R;
|
GPU.world = mat; GPU.sky = SKY_R;
|
||||||
|
GPU.resident = new Uint8Array(NCH);
|
||||||
GPU.worldTextures = Array.from({ length: 8 }, (_, i) => gpuTexture(i === 7 ? FW : W, i === 7 ? FH : H, 'r8uint'));
|
GPU.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);
|
GPU.temp = gpuTexture(W, H, 'r32float'); GPU.worldTextures.push(GPU.temp);
|
||||||
gpuWrite(GPU.worldTextures[6], noiseB, W, 0, 0, W, H);
|
gpuWrite(GPU.worldTextures[6], noiseB, W, 0, 0, W, H);
|
||||||
const tables = new Float32Array((NMAT * 3 + 700 + 551 * 3) * 4);
|
const tables = new Float32Array((NMAT * 3 + 700 + 551 * 3 + 3 * 512) * 4);
|
||||||
for (let m = 0; m < NMAT; m++) {
|
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);
|
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 (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);
|
for (const [j, palette] of Object.values(THERM).entries()) for (let i = 0; i < 551; i++) tables.set([palette[i * 3], palette[i * 3 + 1], palette[i * 3 + 2], 0], (NMAT * 3 + 700 + j * 551 + i) * 4);
|
||||||
|
tables.set(gpuCrackTables(), (NMAT * 3 + 700 + 551 * 3) * 4);
|
||||||
GPU.lut = gpuBuffer(tables);
|
GPU.lut = gpuBuffer(tables);
|
||||||
GPU.landData = new Float32Array((W + H) * 4);
|
GPU.landData = new Float32Array((W + H) * 4);
|
||||||
GPU.land = gpuBuffer(GPU.landData);
|
GPU.land = gpuBuffer(GPU.landData);
|
||||||
@@ -35,18 +37,19 @@ function gpuWorldTextures() {
|
|||||||
GPU.bindColour = null;
|
GPU.bindColour = null;
|
||||||
}
|
}
|
||||||
function gpuViewTextures() {
|
function gpuViewTextures() {
|
||||||
const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}/${+R.smoothOn}`;
|
const key = `${R.bw}/${R.bh}/${R.lw}/${R.lh}/${+R.smoothOn}/${R.canvas.width}/${R.canvas.height}`;
|
||||||
if (GPU.viewKey === key) return;
|
if (GPU.viewKey === key) return;
|
||||||
GPU.viewKey = key; GPU.lightFrame = undefined;
|
GPU.viewKey = key; GPU.lightFrame = undefined;
|
||||||
if (GPU.viewTextures) for (const t of GPU.viewTextures) t.destroy();
|
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;
|
const bw = R.bw, bh = R.bh, gw = R.glowC.width, gh = R.glowC.height;
|
||||||
|
GPU.crackTex = gpuTexture(R.canvas.width, R.canvas.height, 'rgba8unorm', true);
|
||||||
GPU.override = gpuTexture(bw, bh); GPU.overrideGlow = gpuTexture(gw, gh);
|
GPU.override = gpuTexture(bw, bh); GPU.overrideGlow = gpuTexture(gw, gh);
|
||||||
GPU.colourTex = gpuTexture(bw, bh, 'rgba8unorm', true);
|
GPU.colourTex = gpuTexture(bw, bh, 'rgba8unorm', true);
|
||||||
GPU.glowTex = gpuTexture(gw, gh, 'rgba8unorm', true);
|
GPU.glowTex = gpuTexture(gw, gh, 'rgba8unorm', true);
|
||||||
GPU.blurX = gpuTexture(gw, gh, 'rgba8unorm', true); GPU.blurY = gpuTexture(gw, gh, 'rgba8unorm', true);
|
GPU.blurX = gpuTexture(gw, gh, 'rgba8unorm', true); GPU.blurY = gpuTexture(gw, gh, 'rgba8unorm', true);
|
||||||
GPU.lightTex = gpuTexture(Math.max(1, R.lw), Math.max(1, R.lh), 'rgba8unorm', true);
|
GPU.lightTex = gpuTexture(Math.max(1, R.lw), Math.max(1, R.lh), 'rgba8unorm', true);
|
||||||
GPU.lightWork = [0,1].map(() => gpuTexture(Math.max(1,R.lw),Math.max(1,R.lh),'rgba16float',true));
|
GPU.lightWork = [0,1].map(() => gpuTexture(Math.max(1,R.lw),Math.max(1,R.lh),'rgba16float',true));
|
||||||
GPU.viewTextures = [GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex, ...GPU.lightWork];
|
GPU.viewTextures = [GPU.crackTex, GPU.override, GPU.overrideGlow, GPU.colourTex, GPU.glowTex, GPU.blurX, GPU.blurY, GPU.lightTex, ...GPU.lightWork];
|
||||||
if (R.smoothOn) gpuSmoothTextures();
|
if (R.smoothOn) gpuSmoothTextures();
|
||||||
GPU.bindColour = null;
|
GPU.bindColour = null;
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,8 @@ function gpuBindings() {
|
|||||||
GPU.bindColour = group(GPU.colour, [buffer(GPU.uniform), buffer(GPU.lut), buffer(GPU.land), ...GPU.worldTextures.slice(0,9).map(view), view(GPU.override), view(GPU.overrideGlow), view(GPU.colourTex), view(GPU.glowTex)]);
|
GPU.bindColour = group(GPU.colour, [buffer(GPU.uniform), buffer(GPU.lut), buffer(GPU.land), ...GPU.worldTextures.slice(0,9).map(view), view(GPU.override), view(GPU.overrideGlow), view(GPU.colourTex), view(GPU.glowTex)]);
|
||||||
if (R.smoothOn) gpuSmoothBindings(group, view, buffer);
|
if (R.smoothOn) gpuSmoothBindings(group, view, buffer);
|
||||||
gpuLightBindings(group, view, buffer);
|
gpuLightBindings(group, view, buffer);
|
||||||
|
GPU.bindCracks = group(GPU.cracks, [buffer(GPU.uniform),buffer(GPU.lut),view(GPU.worldTextures[0]),view(GPU.worldTextures[5]),view(GPU.crackTex)]);
|
||||||
|
GPU.bindCrackDraw = group(GPU.crackDraw, [view(GPU.crackTex)]);
|
||||||
GPU.bindDraw = group(GPU.draw, [buffer(GPU.uniform), view(GPU.colourTex), view(GPU.lightTex), view(GPU.blurY), GPU.sampler]);
|
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();
|
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.blurUniforms = [0, 1].map(() => d.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }));
|
||||||
@@ -67,20 +72,9 @@ function gpuUpload(lighting, glowOn) {
|
|||||||
const margin = lighting ? Math.ceil(28 / R.lres) * R.lres + R.lres : 2;
|
const margin = lighting ? Math.ceil(28 / R.lres) * R.lres + R.lres : 2;
|
||||||
const x = Math.max(0, R.vx0 - margin), y = Math.max(0, R.vy0 - margin);
|
const x = Math.max(0, R.vx0 - margin), y = Math.max(0, R.vy0 - margin);
|
||||||
const w = Math.min(W, R.vx0 + R.bw + margin) - x, h = Math.min(H, R.vy0 + R.bh + margin + 12) - y;
|
const w = Math.min(W, R.vx0 + R.bw + margin) - x, h = Math.min(H, R.vy0 + R.bh + margin + 12) - 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);
|
|
||||||
if (lighting) {
|
|
||||||
let top = y;
|
let top = y;
|
||||||
for(let xx=x;xx<x+w;xx++) top=Math.min(top,roofY[xx]);
|
if (lighting) for (let xx = x; xx < x + w; xx++) top = Math.min(top, roofY[xx]);
|
||||||
// Roof scans can start above the view; loose loads also inspect twelve grains below.
|
gpuUploadChunks(x, top, x + w, y + h);
|
||||||
for(const [t,a] of [[GPU.worldTextures[0],mat],[GPU.worldTextures[1],flags],[GPU.worldTextures[3],part]]) gpuWrite(t,a,W,x,top,w,y+h-top);
|
|
||||||
gpuWrite(GPU.mach,mach,W,x,top,w,y+h-top);
|
|
||||||
const bx=x>>2, by=top>>2;
|
|
||||||
gpuWrite(GPU.block,blockP,BW,bx,by,Math.min(BW,Math.ceil((x+w)/4))-bx,Math.min(BH,Math.ceil((y+h)/4))-by);
|
|
||||||
}
|
|
||||||
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.
|
// Small per-row/column tables may change in scene tools independently of world allocation.
|
||||||
const land = GPU.landData;
|
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 < 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]; }
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Upload full chunks so scrolling within a resident chunk needs no extra copy.
|
||||||
|
function gpuUploadChunks(x0, y0, x1, y1) {
|
||||||
|
const cx0 = Math.max(0, x0 >> 5), cy0 = Math.max(0, y0 >> 5);
|
||||||
|
const cx1 = Math.min(CW, Math.ceil(x1 / 32)), cy1 = Math.min(CH, Math.ceil(y1 / 32));
|
||||||
|
GPU.uploadStrips = 0; GPU.uploadCells = 0;
|
||||||
|
for (let cy = cy0; cy < cy1; cy++) for (let cx = cx0; cx < cx1;) {
|
||||||
|
const c = cy * CW + cx;
|
||||||
|
if (!renderDirty[c] && GPU.resident[c]) { cx++; continue; }
|
||||||
|
const first = cx++;
|
||||||
|
while (cx < cx1 && (renderDirty[cy * CW + cx] || !GPU.resident[cy * CW + cx])) cx++;
|
||||||
|
const x = first * 32, y = cy * 32, w = Math.min(W, cx * 32) - x, h = Math.min(32, H - y);
|
||||||
|
gpuUploadStrip(x, y, w, h);
|
||||||
|
GPU.uploadStrips++; GPU.uploadCells += w * h;
|
||||||
|
for (let k = first; k < cx; k++) { renderDirty[cy * CW + k] = 0; GPU.resident[cy * CW + k] = 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function gpuUploadStrip(x, y, w, h) {
|
||||||
|
for (const [i, a] of [mat, flags, life, part, bgm, digp].entries()) gpuWrite(GPU.worldTextures[i], a, W, x, y, w, h);
|
||||||
|
gpuWrite(GPU.temp, temp, W, x, y, w, h);
|
||||||
|
gpuWrite(GPU.mach, mach, W, x, y, w, h);
|
||||||
|
gpuWrite(GPU.block, blockP, BW, x >> 2, y >> 2, Math.ceil(w / BS), Math.ceil(h / BS));
|
||||||
|
// Fog interpolation reads the next sample, possibly in a neighbouring chunk.
|
||||||
|
gpuWrite(GPU.worldTextures[7], fog, FW, x / FOG, y / FOG, Math.min(FW - x / FOG, Math.ceil(w / FOG) + 1), Math.min(FH - y / FOG, Math.ceil(h / FOG) + 1));
|
||||||
|
}
|
||||||
|
// Debug-only readback: compares resident GPU texels to CPU arrays without refreshing them.
|
||||||
|
async function gpuCheckUploads() {
|
||||||
|
const sources = [...[mat, flags, life, part, bgm, digp].map((a,i)=>[GPU.worldTextures[i],a,W,1]),
|
||||||
|
[GPU.temp,temp,W,1], [GPU.mach,mach,W,1], [GPU.block,blockP,BW,BS], [GPU.worldTextures[7],fog,FW,FOG]];
|
||||||
|
let checked = 0;
|
||||||
|
for (const [texture, data, stride, scale] of sources) {
|
||||||
|
const bytes = data.BYTES_PER_ELEMENT, row = Math.ceil(texture.width * bytes / 256) * 256;
|
||||||
|
const buffer = GPU.device.createBuffer({size:row*texture.height,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
|
||||||
|
const encoder = GPU.device.createCommandEncoder();
|
||||||
|
encoder.copyTextureToBuffer({texture},{buffer,bytesPerRow:row},[texture.width,texture.height]);
|
||||||
|
GPU.device.queue.submit([encoder.finish()]); await buffer.mapAsync(GPUMapMode.READ);
|
||||||
|
const actual = new Uint8Array(buffer.getMappedRange()), expected = new Uint8Array(data.buffer,data.byteOffset,data.byteLength);
|
||||||
|
let mismatch = '';
|
||||||
|
for (let cy=0;cy<CH;cy++) for(let cx=0;cx<CW;cx++) {
|
||||||
|
const c=cy*CW+cx; if(!GPU.resident[c] || renderDirty[c]) continue;
|
||||||
|
for(let y=cy*32/scale;y<Math.min(texture.height,(cy+1)*32/scale);y++)
|
||||||
|
for(let x=cx*32/scale;x<Math.min(texture.width,(cx+1)*32/scale);x++) for(let b=0;b<bytes;b++) {
|
||||||
|
checked++; if(actual[y*row+x*bytes+b]!==expected[(y*stride+x)*bytes+b] && !mismatch) mismatch=`${texture.format} at ${x},${y}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.unmap(); buffer.destroy(); if(mismatch) throw Error('Stale GPU upload: '+mismatch);
|
||||||
|
}
|
||||||
|
return checked;
|
||||||
|
}
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
// Propagated coloured light from glowing and burning cells.
|
|
||||||
// ---------- Lighting ----------
|
|
||||||
// Light is computed on a coarse grid (2 or 4 grains per cell), written into a
|
|
||||||
// small canvas, then the GPU upscales it smoothly and multiplies it over the world.
|
|
||||||
function builtForLight(i, x, y) {
|
|
||||||
return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i];
|
|
||||||
}
|
|
||||||
function looseForLight(i) {
|
|
||||||
for (let depth = 0; depth <= 12 && i < N; depth++, i += W) {
|
|
||||||
const m = mat[i], ph = PH[m];
|
|
||||||
if (builtForLight(i, i % W, (i / W) | 0)) return true;
|
|
||||||
if (ph !== PH_POWDER && ph !== PH_LIQUID) return m === EMPTY || ph === PH_GAS;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
function computeLight() {
|
|
||||||
const res = SET.lighting === 'fast' ? 4 : 2;
|
|
||||||
const M = Math.ceil(28 / res);
|
|
||||||
const lx0 = Math.floor(R.vx0 / res) - M, ly0 = Math.floor(R.vy0 / res) - M;
|
|
||||||
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
|
|
||||||
if (lw !== R.lw || lh !== R.lh || res !== R.lres || !R.lr || R.lr.length !== lw * lh || R.lightC.width !== lw || R.lightC.height !== lh) {
|
|
||||||
R.lw = lw; R.lh = lh; R.lres = res;
|
|
||||||
R.lr = new Float32Array(lw * lh); R.lg = new Float32Array(lw * lh); R.lb = new Float32Array(lw * lh); R.latt = new Float32Array(lw * lh);
|
|
||||||
R.lightC.width = lw; R.lightC.height = lh;
|
|
||||||
R.lightImg = R.lightX.createImageData(lw, lh);
|
|
||||||
R.lightPx = new Uint32Array(R.lightImg.data.buffer);
|
|
||||||
R.lightRoof = new Int32Array(lw);
|
|
||||||
}
|
|
||||||
R.lx0 = lx0; R.ly0 = ly0;
|
|
||||||
const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt;
|
|
||||||
// per-grain transmittance: air .978, gas .96, liquid .93, solid .935 (light soaks a little way into rock)
|
|
||||||
const P = res;
|
|
||||||
const aAir = Math.pow(0.978, P), aGas = Math.pow(0.96, P), aLiq = Math.pow(0.93, P), aSol = Math.pow(0.9, P);
|
|
||||||
const skyK = SET.daylight * (1 - 0.32 * (R.rain || 0)); // overcast while it rains
|
|
||||||
// Only sampled view columns, starting at the simulation roof; nothing below the view matters.
|
|
||||||
const roofEnd = Math.min(H, (ly0 + lh) * res), lightRoof = R.lightRoof;
|
|
||||||
for (let lx = 0; lx < lw; lx++) {
|
|
||||||
const x = (lx0 + lx) * res;
|
|
||||||
if (x < 0 || x >= W) continue;
|
|
||||||
let y = roofY[x];
|
|
||||||
for (; y < roofEnd; y++) {
|
|
||||||
const i = y * W + x, ph = PH[mat[i]];
|
|
||||||
if ((ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) && !builtForLight(i, x, y) && !((ph === PH_POWDER || ph === PH_LIQUID) && looseForLight(i))) break;
|
|
||||||
}
|
|
||||||
lightRoof[lx] = y;
|
|
||||||
}
|
|
||||||
for (let ly = 0; ly < lh; ly++) {
|
|
||||||
const wy = (ly0 + ly) * res;
|
|
||||||
for (let lx = 0; lx < lw; lx++) {
|
|
||||||
const wx = (lx0 + lx) * res, k = ly * lw + lx;
|
|
||||||
if (wx < 0 || wy < 0 || wx >= W || wy >= H) { la[k] = 0; lr[k] = lg[k] = lb[k] = 0; continue; }
|
|
||||||
const i = wy * W + wx, m = mat[i], ph = PH[m];
|
|
||||||
la[k] = builtForLight(i, wx, wy) || ((ph === PH_POWDER || ph === PH_LIQUID) && looseForLight(i)) ? aAir : ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol;
|
|
||||||
let r = 0, g = 0, b = 0;
|
|
||||||
if (wy < lightRoof[lx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; }
|
|
||||||
else if (wy < surfY[wx] && ph !== PH_SOLID) { const v = skyK * 0.9; r = v; g = v * 0.98; b = v * 0.95; }
|
|
||||||
// emission from heat: hottest grain in the block
|
|
||||||
let T = 0;
|
|
||||||
const xe = Math.min(W, wx + res), ye = Math.min(H, wy + res);
|
|
||||||
for (let yy = wy; yy < ye; yy++) { const row = yy * W; for (let xx = wx; xx < xe; xx++) { const tt = temp[row + xx]; if (tt > T) T = tt; } }
|
|
||||||
if (T > 520) {
|
|
||||||
const gi = glowIndex(T), e = GLOW_W[gi] * 1.35;
|
|
||||||
r += GLOW_R[gi] / 255 * e; g += GLOW_G[gi] / 255 * e; b += GLOW_B[gi] / 255 * e;
|
|
||||||
}
|
|
||||||
lr[k] = r; lg[k] = g; lb[k] = b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// bot lamp
|
|
||||||
if (bot.lamp) {
|
|
||||||
const cx = (bot.x + bot.w / 2) / res - lx0, cy = (bot.y + bot.h / 2) / res - ly0, rad = 34 / res;
|
|
||||||
for (let ly = Math.max(0, Math.floor(cy - rad)); ly < Math.min(lh, cy + rad); ly++)
|
|
||||||
for (let lx = Math.max(0, Math.floor(cx - rad)); lx < Math.min(lw, cx + rad); lx++) {
|
|
||||||
const d = Math.hypot(lx - cx, ly - cy) / rad;
|
|
||||||
if (d >= 1) continue;
|
|
||||||
const v = (1 - d) * (1 - d) * 1.05, k = ly * lw + lx;
|
|
||||||
if (lr[k] < v) lr[k] = v; if (lg[k] < v * 0.93) lg[k] = v * 0.93; if (lb[k] < v * 0.8) lb[k] = v * 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// propagate: forward sweep (from left/up) then backward sweep (from right/down)
|
|
||||||
for (let ly = 0; ly < lh; ly++) for (let lx = 0; lx < lw; lx++) {
|
|
||||||
const k = ly * lw + lx, a = la[k];
|
|
||||||
let r = lr[k], g = lg[k], b = lb[k];
|
|
||||||
if (lx > 0) { const j = k - 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
|
||||||
if (ly > 0) { const j = k - lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
|
||||||
lr[k] = r; lg[k] = g; lb[k] = b;
|
|
||||||
}
|
|
||||||
for (let ly = lh - 1; ly >= 0; ly--) for (let lx = lw - 1; lx >= 0; lx--) {
|
|
||||||
const k = ly * lw + lx, a = la[k];
|
|
||||||
let r = lr[k], g = lg[k], b = lb[k];
|
|
||||||
if (lx < lw - 1) { const j = k + 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
|
||||||
if (ly < lh - 1) { const j = k + lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
|
|
||||||
lr[k] = r; lg[k] = g; lb[k] = b;
|
|
||||||
}
|
|
||||||
// write the light canvas
|
|
||||||
const fl = SET.ambient, px = R.lightPx, n = lw * lh;
|
|
||||||
for (let k = 0; k < n; k++) {
|
|
||||||
let r = lr[k], g = lg[k], b = lb[k];
|
|
||||||
if (r < fl) r = fl; if (g < fl) g = fl; if (b < fl) b = fl;
|
|
||||||
r = r >= 1 ? 255 : (r * 255) | 0; g = g >= 1 ? 255 : (g * 255) | 0; b = b >= 1 ? 255 : (b * 255) | 0;
|
|
||||||
px[k] = 0xff000000 | (b << 16) | (g << 8) | r;
|
|
||||||
}
|
|
||||||
if (!R.smoothOn && !R.gpuOn) R.lightX.putImageData(R.lightImg, 0, 0);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ const R = {
|
|||||||
canvas: null, ctx: null, buf: null, bctx: null, img: null, px: null,
|
canvas: null, ctx: null, buf: null, bctx: null, img: null, px: null,
|
||||||
glowC: null, glowX: null, glowImg: null, glowPx: null,
|
glowC: null, glowX: null, glowImg: null, glowPx: null,
|
||||||
bw: 0, bh: 0, zp: 3, camX: 0, camY: 0, vx0: 0, vy0: 0,
|
bw: 0, bh: 0, zp: 3, camX: 0, camY: 0, vx0: 0, vy0: 0,
|
||||||
lr: null, lg: null, lb: null, latt: null, lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
|
lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
|
||||||
frame: 0, lens: false, particles: [],
|
frame: 0, lens: false, particles: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,8 +143,6 @@ function initRender(canvas) {
|
|||||||
R.bctx = R.buf.getContext('2d');
|
R.bctx = R.buf.getContext('2d');
|
||||||
R.glowC = document.createElement('canvas');
|
R.glowC = document.createElement('canvas');
|
||||||
R.glowX = R.glowC.getContext('2d');
|
R.glowX = R.glowC.getContext('2d');
|
||||||
R.lightC = document.createElement('canvas');
|
|
||||||
R.lightX = R.lightC.getContext('2d');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeRender() {
|
function resizeRender() {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
// Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
|
|
||||||
const SM_SLOPE_HELPERS = `
|
|
||||||
// The same rounded strokes as slopeBeltShape; y/z carry distance along/across the band.
|
|
||||||
vec3 slopeStroke(vec2 p, vec2 a, vec2 b, float origin) {
|
|
||||||
vec2 ray = b - a; float len = length(ray); ray /= len;
|
|
||||||
float along = dot(p - a, ray), across = dot(p - a, vec2(-ray.y, ray.x));
|
|
||||||
vec2 q = abs(vec2(along - len * 0.5, across)) - vec2(len * 0.5 - 0.75, 1.1);
|
|
||||||
float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - 0.75;
|
|
||||||
return vec3(sd, origin + along, across + 2.0);
|
|
||||||
}
|
|
||||||
vec3 slopeCorner(vec2 p, vec2 a, float origin) {
|
|
||||||
vec2 d = p - a; float radius = length(d);
|
|
||||||
return vec3(radius - 1.85, origin + d.x, 2.0 + d.y);
|
|
||||||
}
|
|
||||||
vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) {
|
|
||||||
if ((joins & 64) != 0) p.y -= 0.45;
|
|
||||||
float sy = grade < 0.0 ? 4.0 : 0.0;
|
|
||||||
vec2 a = vec2(-grade * 1.30814755, sy + 1.30814755), b = vec2(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755);
|
|
||||||
if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; }
|
|
||||||
else if ((joins & 4) != 0) a -= vec2(4.0, grade * 4.0);
|
|
||||||
if ((joins & 2) != 0) { b.y = 6.0 - sy; b.x = (b.y - sy - 2.61629509) / grade; }
|
|
||||||
else if ((joins & 8) != 0) b += vec2(4.0, grade * 4.0);
|
|
||||||
vec2 axis = vec2(1.0, grade) * 0.70710678;
|
|
||||||
vec3 best = slopeStroke(p, a, b, dot(worldOrigin + a, axis));
|
|
||||||
if ((joins & 1) != 0) {
|
|
||||||
vec3 h = slopeStroke(p, vec2(-2.0, a.y), a, worldOrigin.x - 2.0);
|
|
||||||
if (h.x < best.x) best = h;
|
|
||||||
h = slopeCorner(p, a, worldOrigin.x + a.x);
|
|
||||||
if (h.x < best.x) best = h;
|
|
||||||
}
|
|
||||||
if ((joins & 2) != 0) {
|
|
||||||
vec3 h = slopeStroke(p, b, vec2(6, b.y), worldOrigin.x + b.x);
|
|
||||||
if (h.x < best.x) best = h;
|
|
||||||
h = slopeCorner(p, b, worldOrigin.x + b.x);
|
|
||||||
if (h.x < best.x) best = h;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SM_SLOPE_DRAW = `
|
|
||||||
if (nc == 31 || nc == 32) {
|
|
||||||
vec4 tag = cellAt(tTree, c);
|
|
||||||
float grade = nc == 31 ? -1.0 : 1.0;
|
|
||||||
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
|
|
||||||
float sy = grade < 0.0 ? 4.0 : 0.0;
|
|
||||||
float foot = (local.x + grade * (local.y - sy)) * 0.5;
|
|
||||||
float depth = (grade * local.x - local.y + sy) * 0.70710678;
|
|
||||||
int wallFlags = int(tag.a * 255.0 + 0.5);
|
|
||||||
float ends = max((wallFlags & 8) == 0 ? -foot : -100.0, (wallFlags & 16) == 0 ? foot - 4.0 : -100.0);
|
|
||||||
float sd = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)}));
|
|
||||||
float phase = float(wallFlags & 7);
|
|
||||||
float wave = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265);
|
|
||||||
vec3 rubber = mix(vec3(0.20,0.22,0.25), vec3(0.25,0.27,0.31), wave);
|
|
||||||
C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd));
|
|
||||||
// the ribs, where the simulation has them: rubber bars standing straight out of an uphill belt,
|
|
||||||
// one every ${CLEAT_SPACING} face cells, moved along by the belt's phase
|
|
||||||
float dirS = (wallFlags & 32) != 0 ? 1.0 : -1.0;
|
|
||||||
if (grade * dirS < 0.0) {
|
|
||||||
float rc = (w.x - local.x) + foot - (grade < 0.0 ? 1.0 : 0.0) - dirS * phase;
|
|
||||||
float off = (mod(rc + ${(CLEAT_SPACING / 2).toFixed(1)}, ${CLEAT_SPACING.toFixed(1)}) - ${(CLEAT_SPACING / 2).toFixed(1)}) * 1.41421356;
|
|
||||||
float ribTop = ${((2 * CLEAT_REACH + 1) * Math.SQRT1_2).toFixed(3)};
|
|
||||||
float rd = length(vec2(off, depth - clamp(depth, 0.0, ribTop))) - 0.5;
|
|
||||||
vec3 rib = mix(vec3(0.50,0.52,0.56), vec3(0.70,0.72,0.76), smoothstep(ribTop - 1.2, ribTop, depth));
|
|
||||||
rib *= mix(0.78, 1.0, smoothstep(-0.5, 0.2, off * -dirS * grade));
|
|
||||||
C = mix(C, rib, 1.0 - smoothstep(-px, px, rd));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slopes use the flat belt's band, rollers and mesh in coordinates along the run.
|
|
||||||
if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
|
|
||||||
(int(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 &&
|
|
||||||
cellAt(tTree, c).r * 255.0 >= 22.5 && cellAt(tTree, c).r * 255.0 <= 30.5) {
|
|
||||||
int slopeClass = int(cellAt(tTree, c).r * 255.0 + 0.5);
|
|
||||||
int shape = (slopeClass - 23) / 2;
|
|
||||||
bool mesh = shape >= 2;
|
|
||||||
float grade = (shape == 0 || shape == 2) ? -1.0 : 1.0;
|
|
||||||
float dir = ((slopeClass - 23) % 2) == 1 ? 1.0 : -1.0;
|
|
||||||
vec4 tag = cellAt(tTree, c);
|
|
||||||
int joins = int(tag.a * 255.0 + 0.5);
|
|
||||||
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
|
|
||||||
vec3 shapePos = slopeShape(local, w - local, grade, joins);
|
|
||||||
float sd = shapePos.x, axis = shapePos.y, depth = shapePos.z;
|
|
||||||
float time = !mesh && (joins & 16) != 0 ? uTime : 0.0;
|
|
||||||
vec2 lb = vec2(axis, depth), alongWorld = vec2(axis, depth);
|
|
||||||
float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5));
|
|
||||||
vec3 belt;
|
|
||||||
if (-sd < (mesh ? 1.1 : 0.8)) {
|
|
||||||
belt = mesh ? mix(vec3(0.22,0.25,0.29), vec3(0.48,0.53,0.59), depth < 2.0 ? 0.85 : 0.35) : bandColor(lb, rg);
|
|
||||||
belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
|
|
||||||
} else if (mesh) {
|
|
||||||
vec2 cg = abs(fract(alongWorld) - 0.5);
|
|
||||||
float bw2 = max(px * 0.9, 0.09);
|
|
||||||
float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
|
|
||||||
vec3 steel = dot(own.rgb, vec3(1)) > 0.01 ? mix(vec3(0.42, 0.44, 0.5), own.rgb, 0.4) : vec3(0.42, 0.44, 0.5);
|
|
||||||
belt = mix(steel * 0.78, C * 0.8, hole);
|
|
||||||
} else {
|
|
||||||
vec2 dv = vec2(mod(axis, 2.0) - 1.0, depth - 2.0);
|
|
||||||
float rd = length(dv);
|
|
||||||
belt = vec3(0.15, 0.16, 0.18);
|
|
||||||
if (rd < 0.95) {
|
|
||||||
float ang = atan(dv.y, dv.x) - dir * time * 30.0 / 0.95;
|
|
||||||
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
|
|
||||||
belt = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
|
|
||||||
belt *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
|
|
||||||
belt *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
C = mix(C, belt, 1.0 - smoothstep(-px, px, sd));
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
// Smooth shaders: vertex and fragment programs for the rounded world look.
|
|
||||||
const SM_VS = `#version 300 es
|
|
||||||
void main() {
|
|
||||||
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
|
||||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const SM_FS = `#version 300 es
|
|
||||||
precision highp float;
|
|
||||||
precision highp int;
|
|
||||||
uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow;
|
|
||||||
uniform vec2 uSize, uOrigin, uLightSize, uGlowSize;
|
|
||||||
uniform vec4 uView, uLightMap;
|
|
||||||
uniform float uGlowK, uTime, uSurf, uRain;
|
|
||||||
out vec4 o;
|
|
||||||
|
|
||||||
float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
|
|
||||||
float vn(vec2 p) {
|
|
||||||
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
|
|
||||||
return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y);
|
|
||||||
}
|
|
||||||
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
|
|
||||||
|
|
||||||
vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); }
|
|
||||||
// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes
|
|
||||||
vec4 bsp(sampler2D t, vec2 q) {
|
|
||||||
vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f;
|
|
||||||
vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
|
|
||||||
vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0;
|
|
||||||
vec2 g0 = w0 + w1, g1 = w2 + w3;
|
|
||||||
vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize;
|
|
||||||
return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0))
|
|
||||||
+ g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0));
|
|
||||||
}
|
|
||||||
vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); }
|
|
||||||
int cls(vec4 c) { return int(c.a * 255.0 + 0.5); }
|
|
||||||
float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; }
|
|
||||||
float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains
|
|
||||||
float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; }
|
|
||||||
float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; }
|
|
||||||
float isN(int n, int k) { return n == k ? 1.0 : 0.0; }
|
|
||||||
// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where
|
|
||||||
// the line of belts ends, the end is square with rounded corners. Negative inside.
|
|
||||||
float beltSD(vec2 lb, bool endL, bool endR) {
|
|
||||||
float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75;
|
|
||||||
vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85);
|
|
||||||
vec2 q = abs(lb - c) - hs + r;
|
|
||||||
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
|
|
||||||
}
|
|
||||||
// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so
|
|
||||||
// the line only rounds off where the whole run ends.
|
|
||||||
float beltLineSD(ivec2 c, vec2 lb) {
|
|
||||||
int caps = int(cellAt(tTree, c).a * 255.0 + 0.5);
|
|
||||||
return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0);
|
|
||||||
}
|
|
||||||
// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and
|
|
||||||
// back along the bottom.
|
|
||||||
float bandRidge(vec2 w, vec2 lb, float dir) {
|
|
||||||
float run = lb.y < 2.0 ? 1.0 : -1.0;
|
|
||||||
return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5));
|
|
||||||
}
|
|
||||||
vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); }
|
|
||||||
|
|
||||||
${SM_SLOPE_HELPERS}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
float zp = uView.z, px = 1.0 / zp;
|
|
||||||
vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y);
|
|
||||||
vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded
|
|
||||||
vec2 w = q + uOrigin; // position in the world
|
|
||||||
vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner
|
|
||||||
vec2 scr = q - uView.xy; // grains from the screen's top-left corner
|
|
||||||
float shc = uView.w * px; // screen height in grains
|
|
||||||
ivec2 c = ivec2(floor(q));
|
|
||||||
vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq);
|
|
||||||
vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1));
|
|
||||||
int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3);
|
|
||||||
vec4 own = cellAt(tCol, c); int nc = cls(own);
|
|
||||||
vec2 s = smoothstep(0.1, 0.9, t);
|
|
||||||
vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
|
|
||||||
|
|
||||||
// fields, and everything that needs screen derivatives (worked out before any branching)
|
|
||||||
vec4 Fb = bsp(tField, q);
|
|
||||||
vec4 F = mix(lin(tField, q), Fb, 0.62);
|
|
||||||
// loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter"
|
|
||||||
vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
|
|
||||||
float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
|
|
||||||
float M = clamp(F.r + pw, 0.0, 1.0);
|
|
||||||
float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
|
|
||||||
float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4);
|
|
||||||
vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g));
|
|
||||||
|
|
||||||
// ---- behind everything: sky with clouds and far hills, or the cave's back wall
|
|
||||||
vec4 back = lin(tBack, q);
|
|
||||||
vec3 C = back.rgb;
|
|
||||||
float sky = back.a;
|
|
||||||
if (sky > 0.01) {
|
|
||||||
vec3 S = C;
|
|
||||||
vec2 P0 = scr + cam * 0.08;
|
|
||||||
// clouds; while it rains they thicken and turn grey
|
|
||||||
float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05)));
|
|
||||||
float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0);
|
|
||||||
S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi);
|
|
||||||
float k1 = 0.16; vec2 P1 = scr + cam * k1;
|
|
||||||
float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1;
|
|
||||||
float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7)));
|
|
||||||
float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
|
|
||||||
vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
|
|
||||||
S = mix(S, far, m1);
|
|
||||||
float k2 = 0.38; vec2 P2 = scr + cam * k2;
|
|
||||||
float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2;
|
|
||||||
float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3)));
|
|
||||||
float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
|
|
||||||
vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
|
|
||||||
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
|
|
||||||
S = mix(S, near, m2);
|
|
||||||
C = mix(C, S, sky);
|
|
||||||
}
|
|
||||||
// the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion)
|
|
||||||
float notSky = 1.0 - sky;
|
|
||||||
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
|
|
||||||
float near = textureLod(tField, q / uSize, 2.2).r;
|
|
||||||
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
|
|
||||||
|
|
||||||
// ---- trees (behind the ground and everything on it)
|
|
||||||
if (F.a > 0.2) {
|
|
||||||
vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1));
|
|
||||||
vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
|
|
||||||
float sm = m.x + m.y + m.z + m.w;
|
|
||||||
if (sm > 1e-4) {
|
|
||||||
vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
|
|
||||||
float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
|
|
||||||
float lv = smoothstep(0.6, 0.9, leaf);
|
|
||||||
tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
|
|
||||||
float d = (F.a - 0.36) / fwA;
|
|
||||||
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
|
|
||||||
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- smoke, steam and flame: soft, see-through puffs
|
|
||||||
if (Fb.b > 0.02) {
|
|
||||||
vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
|
|
||||||
float sm = m.x + m.y + m.z + m.w;
|
|
||||||
vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb;
|
|
||||||
if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line
|
|
||||||
float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
|
|
||||||
if (anyL > 0.0 && lf > 0.3) {
|
|
||||||
vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
|
|
||||||
float sm = m.x + m.y + m.z + m.w;
|
|
||||||
vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
|
|
||||||
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25));
|
|
||||||
float gl = length(gL);
|
|
||||||
float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0;
|
|
||||||
float band = 1.0 - smoothstep(0.5, 0.75, F.g);
|
|
||||||
lc += vec3(0.16, 0.18, 0.2) * topSide * band;
|
|
||||||
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the ground: rounded, outlined, with a lit top edge and a shaded underside
|
|
||||||
if (F.r > 0.3) {
|
|
||||||
vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
|
|
||||||
float sm = m.x + m.y + m.z + m.w;
|
|
||||||
vec3 blur = bsp(tColL, q).rgb, tc = own.rgb;
|
|
||||||
float bd = 1e9;
|
|
||||||
if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
|
|
||||||
if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
|
|
||||||
if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
|
|
||||||
if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
|
|
||||||
if (sm < 1e-4) tc = own.rgb;
|
|
||||||
tc *= 0.84 + 0.3 * fbm(w * 0.28);
|
|
||||||
tc *= 0.95 + 0.1 * vn(w * 1.6);
|
|
||||||
float gl = length(gT);
|
|
||||||
vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0);
|
|
||||||
float band = 1.0 - smoothstep(0.5, 0.9, M);
|
|
||||||
float lit = dot(nrm, normalize(vec2(-0.5, -1.0)));
|
|
||||||
tc *= 1.0 + 0.42 * lit * band;
|
|
||||||
// Keep the air outline; a softer, screen-width seam also separates rock from loose grains.
|
|
||||||
float dm = (M - 0.5) / fwM;
|
|
||||||
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
|
|
||||||
float dt = (F.r - 0.5) / fwT;
|
|
||||||
if (nc == 1 && pw > 0.0) tc *= mix(0.65, 1.0, smoothstep(0.0, 1.5, dt));
|
|
||||||
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
|
|
||||||
}
|
|
||||||
|
|
||||||
${SM_SLOPE_DRAW}
|
|
||||||
|
|
||||||
// ---- loose grains: little round pebbles; a pocket closed in by four grains is filled
|
|
||||||
int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
|
|
||||||
bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
|
|
||||||
if (np > 0) {
|
|
||||||
float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6;
|
|
||||||
for (int k = 0; k < 4; k++) {
|
|
||||||
ivec2 o4 = ivec2(k & 1, k >> 1);
|
|
||||||
int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3;
|
|
||||||
if (nk != 2) continue;
|
|
||||||
vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3;
|
|
||||||
vec2 ce = vec2(c0 + o4) + 0.5;
|
|
||||||
float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5);
|
|
||||||
vec2 dv = (q - ce) / r;
|
|
||||||
float dd = length(dv);
|
|
||||||
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
|
|
||||||
}
|
|
||||||
float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best);
|
|
||||||
if (closed && F.r < 0.5) cover = 1.0;
|
|
||||||
// Pebbles stop at the rock face so they cannot paint over its thin seam.
|
|
||||||
cover *= 1.0 - smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT);
|
|
||||||
if (cover > 0.0) {
|
|
||||||
float r2 = min(1.0, dot(gd, gd));
|
|
||||||
vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25));
|
|
||||||
float sh = 0.98 + 0.18 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6))));
|
|
||||||
sh *= mix(1.0, 0.94, smoothstep(0.7 - px * 0.2, 1.15 + px * 0.2, best));
|
|
||||||
vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh;
|
|
||||||
if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best));
|
|
||||||
C = mix(C, pc, cover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines
|
|
||||||
if (nc == 4) C = own.rgb;
|
|
||||||
else if (nc == 7 || nc == 8) {
|
|
||||||
// belt: a rubber band round two copper rollers per block; the top run moves the load
|
|
||||||
float dir = nc == 8 ? 1.0 : -1.0;
|
|
||||||
vec2 lb = fract(w * 0.25) * 4.0;
|
|
||||||
// outline: square ends with rounded corners where the line of belts (and filters) ends
|
|
||||||
float sd = beltLineSD(c, lb);
|
|
||||||
float inside = 1.0 - smoothstep(-px, px, sd);
|
|
||||||
if (inside > 0.0) {
|
|
||||||
float edge = -sd;
|
|
||||||
vec3 bc;
|
|
||||||
if (edge < 0.8) {
|
|
||||||
bc = bandColor(lb, bandRidge(w, lb, dir));
|
|
||||||
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
|
||||||
} else {
|
|
||||||
vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc;
|
|
||||||
float rd = length(dv);
|
|
||||||
bc = vec3(0.15, 0.16, 0.18);
|
|
||||||
if (rd < 0.95) {
|
|
||||||
float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95;
|
|
||||||
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
|
|
||||||
bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
|
|
||||||
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
|
|
||||||
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
C = mix(C, bc, inside);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if ((nc >= 9 && nc <= 12) || (nc >= 19 && nc <= 22)) {
|
|
||||||
// screw conveyor: a copper pipe; what is inside shows through, and the screw's flights turn
|
|
||||||
bool vert = nc == 9 || nc == 11;
|
|
||||||
float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0;
|
|
||||||
float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x;
|
|
||||||
float screwSD = -1.0, capRing = 0.0;
|
|
||||||
vec4 screwTag = cellAt(tTree, c);
|
|
||||||
int screwJoins = int(screwTag.a * 255.0 + 0.5);
|
|
||||||
// Straight bend tags use six bits; background trees use 128/255.
|
|
||||||
bool shaped = nc >= 19 || (screwJoins > 0 && screwJoins < 64);
|
|
||||||
if (shaped) {
|
|
||||||
vec2 d = vec2(1.0, nc == 19 || nc == 21 ? -1.0 : 1.0);
|
|
||||||
vec4 tag = screwTag;
|
|
||||||
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w) - 2.0;
|
|
||||||
int joins = screwJoins;
|
|
||||||
screwSD = 100.0;
|
|
||||||
for (int end = 0; end < 2; end++) {
|
|
||||||
int join = (joins >> (end * 3)) & 7;
|
|
||||||
float side = end == 0 ? -1.0 : 1.0;
|
|
||||||
bool diagonal = nc >= 19 ? join == 0 || join >= 5 : join != 0;
|
|
||||||
float grade = nc >= 19 ? d.y : join == 1 ? -1.0 : 1.0;
|
|
||||||
vec2 ray = diagonal ? (vert ? vec2(side * grade, side) : vec2(side, side * grade)) * 0.70710678 :
|
|
||||||
nc < 19 ? (vert ? vec2(0, side) : vec2(side, 0)) :
|
|
||||||
join == 1 ? vec2(0, -1) : join == 2 ? vec2(1, 0) : join == 3 ? vec2(0, 1) : vec2(-1, 0);
|
|
||||||
float t = dot(local, ray), cross2 = dot(local, vec2(-ray.y, ray.x));
|
|
||||||
float radius = length(vec2(cross2, min(0.0, t))), cap = t - (diagonal ? 2.82842712 : 2.0);
|
|
||||||
// A connected mouth meets another opaque pipe; fading its edge leaves a seam.
|
|
||||||
float sd = max(radius - 2.0, (joins & (64 << end)) != 0 ? cap : -10.0);
|
|
||||||
if (sd < screwSD) {
|
|
||||||
screwSD = sd;
|
|
||||||
a = 2.0 + (side * cross2 < 0.0 ? -radius : radius);
|
|
||||||
along = (nc >= 19 ? dot(w - local, d) * 0.70710678 : vert ? w.y - local.y : w.x - local.x) + side * t;
|
|
||||||
capRing = (joins & (64 << end)) != 0 ? smoothstep(-0.55, -0.15, cap) : 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nc >= 19) sp2 = nc == 19 || nc == 20 ? 1.0 : -1.0;
|
|
||||||
}
|
|
||||||
float wall = 0.62;
|
|
||||||
vec3 tc;
|
|
||||||
if (a < wall || a > 4.0 - wall || capRing > 0.5) {
|
|
||||||
float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore
|
|
||||||
float cyl = sin(clamp(u2, 0.0, 1.0) * 1.5708);
|
|
||||||
tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
|
|
||||||
if (a < wall) tc *= 1.08;
|
|
||||||
float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
|
|
||||||
if (nc < 19) tc *= 1.0 - 0.3 * ring;
|
|
||||||
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
|
|
||||||
} else {
|
|
||||||
bool full = own.r + own.g + own.b > 0.0;
|
|
||||||
float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5);
|
|
||||||
float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
|
|
||||||
vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
|
|
||||||
tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore;
|
|
||||||
tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8));
|
|
||||||
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
|
|
||||||
}
|
|
||||||
if (shaped) {
|
|
||||||
bool full = own.r + own.g + own.b > 0.0;
|
|
||||||
if (full) C = mix(C, own.rgb, 1.0 - smoothstep(0.36, 0.5, length(fract(w) - 0.5)));
|
|
||||||
C = mix(C, tc, 1.0 - smoothstep(2.0 - px, 2.0 + px, screwSD + 2.0));
|
|
||||||
} else C = tc;
|
|
||||||
}
|
|
||||||
else if (nc == 6 || (nc >= 15 && nc <= 18)) {
|
|
||||||
vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb;
|
|
||||||
float e = min(min(lb.x, lb.y), min(rb.x, rb.y));
|
|
||||||
vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
|
|
||||||
float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
|
|
||||||
float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
|
|
||||||
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
|
|
||||||
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
|
|
||||||
if (nc >= 15) {
|
|
||||||
vec2 sl = lb;
|
|
||||||
if (nc == 16 || nc == 18) sl.x = 4.0 - sl.x;
|
|
||||||
if (nc >= 17) sl.y = 4.0 - sl.y;
|
|
||||||
float d = (sl.y - sl.x) * 0.70710678;
|
|
||||||
tc *= 1.0 + 0.2 * (1.0 - smoothstep(0.0, 0.6, d));
|
|
||||||
C = mix(C, tc, smoothstep(-px * 0.5, px * 0.5, d));
|
|
||||||
} else C = tc;
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (nc == 13 || nc == 14) {
|
|
||||||
// filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a
|
|
||||||
// row of belts and filters reads as one belt, rounded only where the whole line ends. The
|
|
||||||
// band is perforated; its colour tag sits on the top run for what rides along, on the
|
|
||||||
// bottom run for what drops through.
|
|
||||||
float dir = nc == 14 ? 1.0 : -1.0;
|
|
||||||
vec2 lb = fract(w * 0.25) * 4.0;
|
|
||||||
vec4 tag = cellAt(tTree, c);
|
|
||||||
bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0;
|
|
||||||
float sd = beltLineSD(c, lb);
|
|
||||||
float inside = 1.0 - smoothstep(-px, px, sd);
|
|
||||||
if (inside > 0.0) {
|
|
||||||
float edge = -sd;
|
|
||||||
vec3 steel = vec3(0.42, 0.44, 0.5);
|
|
||||||
// through the holes: whatever is behind (water, sky, rock), or what is passing through
|
|
||||||
vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8;
|
|
||||||
vec2 cg = abs(fract(w) - 0.5);
|
|
||||||
float bw2 = max(px * 0.9, 0.09);
|
|
||||||
float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
|
|
||||||
vec3 fc;
|
|
||||||
if (edge < 0.8) {
|
|
||||||
float rg = bandRidge(w, lb, dir);
|
|
||||||
vec3 band = bandColor(lb, rg);
|
|
||||||
if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg);
|
|
||||||
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
|
|
||||||
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
|
|
||||||
} else {
|
|
||||||
fc = mix(steel * 0.78, behind, hole);
|
|
||||||
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
|
|
||||||
}
|
|
||||||
C = mix(C, fc, inside);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- light, then the glow of hot things on top
|
|
||||||
if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb;
|
|
||||||
if (uGlowK > 0.0) {
|
|
||||||
vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize;
|
|
||||||
vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25;
|
|
||||||
g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125;
|
|
||||||
g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625;
|
|
||||||
C += g * uGlowK;
|
|
||||||
}
|
|
||||||
o = vec4(C, 1.0);
|
|
||||||
}`;
|
|
||||||
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
// Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
|
|
||||||
const SM = { tried: false, ok: false, why: '', gl: null, canvas: null, prog: null, tex: {}, u: {}, t0: performance.now(), white: null, surf: -1, scale: 1, ms: 0, n: 0 };
|
|
||||||
|
|
||||||
function smCompile(gl, type, src) {
|
|
||||||
const sh = gl.createShader(type);
|
|
||||||
gl.shaderSource(sh, src); gl.compileShader(sh);
|
|
||||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
|
|
||||||
return sh;
|
|
||||||
}
|
|
||||||
// Is the smooth look possible here (WebGL2)? Set up once, on first use.
|
|
||||||
function smoothAvailable() {
|
|
||||||
if (SM.tried) return SM.ok;
|
|
||||||
SM.tried = true;
|
|
||||||
try {
|
|
||||||
const cv = document.createElement('canvas');
|
|
||||||
const gl = cv.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: true, premultipliedAlpha: false });
|
|
||||||
if (!gl) { SM.why = 'nogl'; throw new Error('WebGL2 is not available'); }
|
|
||||||
// a browser drawing without the graphics card would crawl: keep the pixel look there
|
|
||||||
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
|
|
||||||
const rend = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : '';
|
|
||||||
if (/swiftshader|llvmpipe|softpipe|software/i.test(rend) && !window.__allowSoftwareGL) { SM.why = 'software'; throw new Error('software rendering: ' + rend); }
|
|
||||||
const prog = gl.createProgram();
|
|
||||||
gl.attachShader(prog, smCompile(gl, gl.VERTEX_SHADER, SM_VS));
|
|
||||||
gl.attachShader(prog, smCompile(gl, gl.FRAGMENT_SHADER, SM_FS));
|
|
||||||
gl.linkProgram(prog);
|
|
||||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
|
|
||||||
gl.useProgram(prog);
|
|
||||||
for (const n of ['uSize', 'uOrigin', 'uLightSize', 'uGlowSize', 'uView', 'uLightMap', 'uGlowK', 'uTime', 'uSurf', 'uRain']) SM.u[n] = gl.getUniformLocation(prog, n);
|
|
||||||
// texture unit for each sampler; linear ones are sampled between grains
|
|
||||||
const texs = [['tCol', false, false], ['tColL', true, false], ['tBack', true, false], ['tTree', false, false], ['tField', true, true], ['tLight', true, false], ['tGlow', true, false]];
|
|
||||||
texs.forEach(([name, linear, mip], unit) => {
|
|
||||||
const t = gl.createTexture();
|
|
||||||
gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mip ? gl.LINEAR_MIPMAP_LINEAR : linear ? gl.LINEAR : gl.NEAREST);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
||||||
gl.uniform1i(gl.getUniformLocation(prog, name), unit);
|
|
||||||
SM.tex[name] = { t, unit, mip, w: 0, h: 0 };
|
|
||||||
});
|
|
||||||
gl.bindVertexArray(gl.createVertexArray());
|
|
||||||
SM.canvas = cv; SM.gl = gl; SM.prog = prog; SM.ok = true;
|
|
||||||
SM.white = new Uint8Array([255, 255, 255, 255]);
|
|
||||||
cv.addEventListener('webglcontextlost', e => { e.preventDefault(); SM.ok = false; });
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Smooth look unavailable, using pixels:', e && e.message);
|
|
||||||
SM.ok = false;
|
|
||||||
}
|
|
||||||
return SM.ok;
|
|
||||||
}
|
|
||||||
function smUpload(name, w, h, data) {
|
|
||||||
const gl = SM.gl, T = SM.tex[name];
|
|
||||||
gl.activeTexture(gl.TEXTURE0 + T.unit); gl.bindTexture(gl.TEXTURE_2D, T.t);
|
|
||||||
if (T.w !== w || T.h !== h) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); T.w = w; T.h = h; }
|
|
||||||
else gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
|
||||||
if (T.mip) gl.generateMipmap(gl.TEXTURE_2D);
|
|
||||||
}
|
|
||||||
const smBytes = a => new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
|
|
||||||
// Both renderers share the same cached parallax horizon.
|
|
||||||
function smoothSurface() {
|
|
||||||
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { SM.surf = meanSurface(); SM.surfW = W; SM.surfSeed = worldSeed; }
|
|
||||||
return SM.surf;
|
|
||||||
}
|
|
||||||
function smoothDraw(lighting, glowOn) {
|
|
||||||
const t0 = performance.now();
|
|
||||||
// the GPU pass can run below full resolution on a slow graphics card; it is scaled up smoothly
|
|
||||||
const gl = SM.gl, cv = SM.canvas, sc = SM.scale, cw = Math.max(1, Math.round(R.canvas.width * sc)), ch = Math.max(1, Math.round(R.canvas.height * sc)), u = SM.u;
|
|
||||||
if (cv.width !== cw || cv.height !== ch) { cv.width = cw; cv.height = ch; }
|
|
||||||
const surf = smoothSurface();
|
|
||||||
gl.viewport(0, 0, cw, ch);
|
|
||||||
gl.useProgram(SM.prog);
|
|
||||||
const bw = R.bw, bh = R.bh;
|
|
||||||
const uploadStart = PERF_MARKS ? performance.now() : 0;
|
|
||||||
smUpload('tCol', bw, bh, smBytes(R.px));
|
|
||||||
smUpload('tColL', bw, bh, smBytes(R.px));
|
|
||||||
smUpload('tBack', bw, bh, smBytes(R.backPx));
|
|
||||||
smUpload('tTree', bw, bh, smBytes(R.treePx));
|
|
||||||
smUpload('tField', bw, bh, smBytes(R.fieldPx));
|
|
||||||
if (lighting && R.lightImg) smUpload('tLight', R.lw, R.lh, smBytes(R.lightImg.data)); else smUpload('tLight', 1, 1, SM.white);
|
|
||||||
if (glowOn) smUpload('tGlow', R.glowC.width, R.glowC.height, smBytes(R.glowImg.data)); else smUpload('tGlow', 1, 1, SM.white);
|
|
||||||
const blitStart = PERF_MARKS ? performance.now() : 0;
|
|
||||||
if (PERF_MARKS) { perfMarks.upload = blitStart - uploadStart; perfMarks.prepare += uploadStart - t0; }
|
|
||||||
gl.uniform2f(u.uSize, bw, bh);
|
|
||||||
gl.uniform2f(u.uOrigin, R.vx0, R.vy0);
|
|
||||||
gl.uniform4f(u.uView, R.camX - R.vx0, R.camY - R.vy0, R.zp * (ch / R.canvas.height), ch);
|
|
||||||
gl.uniform4f(u.uLightMap, lighting ? R.lx0 * R.lres - R.vx0 : 0, lighting ? R.ly0 * R.lres - R.vy0 : 0, R.lres || 1, lighting ? 1 : 0);
|
|
||||||
gl.uniform2f(u.uLightSize, R.lw || 1, R.lh || 1);
|
|
||||||
gl.uniform2f(u.uGlowSize, R.glowC.width || 1, R.glowC.height || 1);
|
|
||||||
gl.uniform1f(u.uGlowK, glowOn ? (SET.glow === 'high' ? 0.9 : 0.55) : 0);
|
|
||||||
gl.uniform1f(u.uTime, (performance.now() - SM.t0) / 1000);
|
|
||||||
gl.uniform1f(u.uSurf, surf);
|
|
||||||
gl.uniform1f(u.uRain, R.rain || 0);
|
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
||||||
const ctx = R.ctx;
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.drawImage(cv, 0, 0, cw, ch, 0, 0, R.canvas.width, R.canvas.height);
|
|
||||||
if (PERF_MARKS) perfMarks.blit = performance.now() - blitStart;
|
|
||||||
smoothPace(performance.now() - t0);
|
|
||||||
}
|
|
||||||
// Keep the frame rate up: drop the GPU pass's resolution when it is slow, raise it again when there
|
|
||||||
// is room, and if even half resolution is too slow, give up on the smooth look for this session.
|
|
||||||
function smoothPace(ms) {
|
|
||||||
SM.ms = SM.n ? SM.ms * 0.8 + ms * 0.2 : ms;
|
|
||||||
if (++SM.n < 15) return;
|
|
||||||
SM.n = 1;
|
|
||||||
if (SM.ms > 40 && SM.scale <= 0.5) { SM.ok = false; SM.why = 'slow'; return; }
|
|
||||||
if (SM.ms > 18 && SM.scale > 0.5) SM.scale = Math.max(0.5, SM.scale - (SM.ms > 60 ? 0.5 : 0.25));
|
|
||||||
else if (SM.ms < 6 && SM.scale < 1) SM.scale = Math.min(1, SM.scale + 0.25);
|
|
||||||
}
|
|
||||||
@@ -5,18 +5,16 @@ function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 1
|
|||||||
function drawWorld() {
|
function drawWorld() {
|
||||||
const perfStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
|
const perfStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
|
||||||
if (PERF_MARKS || SET.showPerf) { perfMarks.light = 0; perfMarks.override = 0; delete perfMarks.gpu; }
|
if (PERF_MARKS || SET.showPerf) { perfMarks.light = 0; perfMarks.override = 0; delete perfMarks.gpu; }
|
||||||
const wasGpu = R.gpuOn;
|
if (!GPU.ok) return;
|
||||||
R.gpuOn = SET.renderer === 'webgpu' && GPU.ok;
|
R.gpuOn = true; GPU.canvas.hidden = false;
|
||||||
if (R.gpuOn && !wasGpu) GPU.lightFrame = undefined;
|
R.ctx.clearRect(0, 0, R.canvas.width, R.canvas.height);
|
||||||
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;
|
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;
|
||||||
// Smooth inputs carry a class in alpha; only exceptional cells use the CPU in WebGPU mode.
|
// Smooth inputs carry a class in alpha; only exceptional cells use the CPU in WebGPU mode.
|
||||||
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && (R.gpuOn || smoothAvailable());
|
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens;
|
||||||
const mmask = R.moldMask;
|
const mmask = R.moldMask;
|
||||||
if (sm) {
|
if (sm) {
|
||||||
mmask.fill(0);
|
mmask.fill(0);
|
||||||
@@ -34,52 +32,15 @@ function drawWorld() {
|
|||||||
}
|
}
|
||||||
prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh);
|
prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh);
|
||||||
const lighting = SET.lighting !== 'off' && !R.lens;
|
const lighting = SET.lighting !== 'off' && !R.lens;
|
||||||
const lightUpdate = R.gpuOn && lighting ? gpuLightGrid() : false;
|
const lightUpdate = lighting ? gpuLightGrid() : false;
|
||||||
if (!R.gpuOn && lighting && (R.frame % SET.lightEvery === 0 || !R.lw || wasGpu)) {
|
|
||||||
const lightStart = PERF_MARKS ? performance.now() : 0;
|
|
||||||
computeLight();
|
|
||||||
if (PERF_MARKS) perfMarks.light = performance.now() - lightStart;
|
|
||||||
}
|
|
||||||
const colorStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
|
const colorStart = (PERF_MARKS || SET.showPerf) ? performance.now() : 0;
|
||||||
const glowOn = SET.glow !== 'off' && !R.lens;
|
const glowOn = SET.glow !== 'off' && !R.lens;
|
||||||
if (glowOn) R.glowPx.fill(0);
|
if (glowOn) R.glowPx.fill(0);
|
||||||
if (R.gpuOn) gpuOverrides();
|
gpuOverrides();
|
||||||
else for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) cellPixel(vx0 + bx, vy0 + by);
|
|
||||||
worldObjects();
|
worldObjects();
|
||||||
if (PERF_MARKS || SET.showPerf) {
|
if (PERF_MARKS || SET.showPerf) {
|
||||||
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, lightUpdate); return; }
|
gpuDraw(lighting, glowOn, lightUpdate);
|
||||||
if (sm) { smoothDraw(lighting, glowOn); return; }
|
|
||||||
const uploadStart = PERF_MARKS ? performance.now() : 0;
|
|
||||||
R.bctx.putImageData(R.img, 0, 0);
|
|
||||||
const blitStart = PERF_MARKS ? performance.now() : 0;
|
|
||||||
if (PERF_MARKS) perfMarks.upload = blitStart - uploadStart;
|
|
||||||
const ctx = R.ctx, zp = R.zp;
|
|
||||||
ctx.imageSmoothingEnabled = false;
|
|
||||||
const ox = -(R.camX - vx0) * zp, oy = -(R.camY - vy0) * zp;
|
|
||||||
ctx.drawImage(R.buf, 0, 0, bw, bh, Math.round(ox), Math.round(oy), bw * zp, bh * zp);
|
|
||||||
if (lighting) {
|
|
||||||
const res = R.lres;
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalCompositeOperation = 'multiply';
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
const lx = (R.lx0 * res - R.camX) * zp, ly = (R.ly0 * res - R.camY) * zp;
|
|
||||||
ctx.drawImage(R.lightC, 0, 0, R.lw, R.lh, lx, ly, R.lw * res * zp, R.lh * res * zp);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
if (glowOn) {
|
|
||||||
R.glowX.putImageData(R.glowImg, 0, 0);
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalCompositeOperation = 'lighter';
|
|
||||||
ctx.globalAlpha = SET.glow === 'high' ? 0.9 : 0.5;
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
const blur = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2));
|
|
||||||
try { ctx.filter = `blur(${blur.toFixed(1)}px)`; } catch (e) { /* no filter support */ }
|
|
||||||
ctx.drawImage(R.glowC, 0, 0, R.glowC.width, R.glowC.height, Math.round(ox), Math.round(oy), R.glowC.width * 2 * zp, R.glowC.height * 2 * zp);
|
|
||||||
ctx.filter = 'none';
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
if (PERF_MARKS) perfMarks.blit = performance.now() - blitStart;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function toggleGateAt(x, y) {
|
|||||||
const m = mat[i];
|
const m = mat[i];
|
||||||
if (m !== EMPTY && PH[m] !== PH_GAS) {
|
if (m !== EMPTY && PH[m] !== PH_GAS) {
|
||||||
const u = i - W;
|
const u = i - W;
|
||||||
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; }
|
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; renderDirtyIdx(u); }
|
||||||
}
|
}
|
||||||
part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0;
|
part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0;
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ function interactAt(x, y) {
|
|||||||
return open ? 'Rack open.' : 'Rack closed.';
|
return open ? 'Rack open.' : 'Rack closed.';
|
||||||
}
|
}
|
||||||
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
|
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
|
||||||
blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' });
|
blockV[pb] ^= 4; renderDirtyXY((pb % BW) * BS, ((pb / BW) | 0) * BS); emit({ t: 'sfx', s: 'ui' });
|
||||||
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
|
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
|
||||||
}
|
}
|
||||||
const id = mach[y * W + x];
|
const id = mach[y * W + x];
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ function placeBlock(code, v, bx, by, free, extra) {
|
|||||||
else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0);
|
else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0);
|
||||||
}
|
}
|
||||||
if (isPipeCode(code)) pipesDirty = true;
|
if (isPipeCode(code)) pipesDirty = true;
|
||||||
|
renderDirtyXY(bx * BS, by * BS);
|
||||||
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
|
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
|
||||||
if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
|
if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
|
||||||
if (isProcCode(code)) procDirty = true;
|
if (isProcCode(code)) procDirty = true;
|
||||||
@@ -169,6 +170,7 @@ function removeBlock(bx, by) {
|
|||||||
rackState.delete(b);
|
rackState.delete(b);
|
||||||
if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y);
|
if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y);
|
||||||
}
|
}
|
||||||
|
renderDirtyXY(bx * BS, by * BS);
|
||||||
blockP[b] = 0; blockV[b] = 0;
|
blockP[b] = 0; blockV[b] = 0;
|
||||||
const cleared = blockCellList(bx, by);
|
const cleared = blockCellList(bx, by);
|
||||||
if (isSlope(code) || isSlopeBelt(code)) {
|
if (isSlope(code) || isSlopeBelt(code)) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ function setCell(i, m, t, fl) {
|
|||||||
if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y);
|
if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y);
|
||||||
}
|
}
|
||||||
function swapCells(i, j) {
|
function swapCells(i, j) {
|
||||||
|
renderDirtyIdx(i); renderDirtyIdx(j);
|
||||||
let a = mat[i]; mat[i] = mat[j]; mat[j] = a;
|
let a = mat[i]; mat[i] = mat[j]; mat[j] = a;
|
||||||
const f = temp[i]; temp[i] = temp[j]; temp[j] = f;
|
const f = temp[i]; temp[i] = temp[j]; temp[j] = f;
|
||||||
a = life[i]; life[i] = life[j]; life[j] = a;
|
a = life[i]; life[i] = life[j]; life[j] = a;
|
||||||
@@ -149,6 +150,7 @@ function liquid(i, x, y, m) {
|
|||||||
function gas(i, x, y, m) {
|
function gas(i, x, y, m) {
|
||||||
// Even blocked gas must keep aging or condensing until it is gone.
|
// Even blocked gas must keep aging or condensing until it is gone.
|
||||||
awake[(y >> 5) * CW + (x >> 5)] = 3;
|
awake[(y >> 5) * CW + (x >> 5)] = 3;
|
||||||
|
renderDirtyXY(x, y); // Gas can age without moving.
|
||||||
if (m === FIRE) {
|
if (m === FIRE) {
|
||||||
const l = life[i];
|
const l = life[i];
|
||||||
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
|
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ function carryBeltsAndFilters(belts, filters, d, bm, carrying) {
|
|||||||
}
|
}
|
||||||
// Change what a filter does; grains inside it that no longer pass are pushed out on top.
|
// Change what a filter does; grains inside it that no longer pass are pushed out on top.
|
||||||
function setFilter(b, cfg) {
|
function setFilter(b, cfg) {
|
||||||
|
renderDirtyXY((b % BW) * BS, ((b / BW) | 0) * BS);
|
||||||
if (!isFilter(blockP[b])) return false;
|
if (!isFilter(blockP[b])) return false;
|
||||||
const rec = filterRec(cfg);
|
const rec = filterRec(cfg);
|
||||||
filterCfg.set(b, rec);
|
filterCfg.set(b, rec);
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ function pulseTick() {
|
|||||||
const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1;
|
const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1;
|
||||||
if (h > tier.hard) { e *= 0.35; continue; }
|
if (h > tier.hard) { e *= 0.35; continue; }
|
||||||
const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6);
|
const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6);
|
||||||
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else digp[i] = q;
|
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else { digp[i] = q; renderDirtyIdx(i); }
|
||||||
e *= p.tune ? 0.96 : p.damp;
|
e *= p.tune ? 0.96 : p.damp;
|
||||||
}
|
}
|
||||||
p.en[k] = e; p.s[k] = sd;
|
p.en[k] = e; p.s[k] = sd;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = ne
|
|||||||
function fogMark(x, y, v) {
|
function fogMark(x, y, v) {
|
||||||
if (x < 0 || y < 0 || x >= W || y >= H) return;
|
if (x < 0 || y < 0 || x >= W || y >= H) return;
|
||||||
const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG);
|
const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG);
|
||||||
if (v > fog[i]) fog[i] = v;
|
if (v > fog[i]) { fog[i] = v; renderDirtyXY(x, y); }
|
||||||
}
|
}
|
||||||
function fogSeen(x, y) {
|
function fogSeen(x, y) {
|
||||||
if (x < 0 || y < 0 || x >= W || y >= H) return 255;
|
if (x < 0 || y < 0 || x >= W || y >= H) return 255;
|
||||||
@@ -39,7 +39,7 @@ function fogLook(cx, cy) {
|
|||||||
const dx = (fx + 0.5) * FOG - cx, dy = (fy + 0.5) * FOG - cy, d2 = dx * dx + dy * dy, i = fy * FW + fx;
|
const dx = (fx + 0.5) * FOG - cx, dy = (fy + 0.5) * FOG - cy, d2 = dx * dx + dy * dy, i = fy * FW + fx;
|
||||||
if (d2 >= R2) continue;
|
if (d2 >= R2) continue;
|
||||||
const v = d2 <= in2 ? 255 : (FOG_SENSE - Math.sqrt(d2)) * 255 / FOG_SENSE_FADE;
|
const v = d2 <= in2 ? 255 : (FOG_SENSE - Math.sqrt(d2)) * 255 / FOG_SENSE_FADE;
|
||||||
if (v > fog[i]) fog[i] = v;
|
if (v > fog[i]) { fog[i] = v; renderDirtyXY(fx * FOG, fy * FOG); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function fogRevealAround(x, y, r) {
|
function fogRevealAround(x, y, r) {
|
||||||
@@ -49,4 +49,4 @@ function fogRevealAround(x, y, r) {
|
|||||||
fogMark(xx, yy, r >= 6 && d2 <= (r - 6) ** 2 ? 255 : Math.min(255, (r - Math.sqrt(d2)) * 255 / 6));
|
fogMark(xx, yy, r >= 6 && d2 <= (r - 6) ** 2 ? 255 : Math.min(255, (r - Math.sqrt(d2)) * 255 / 6));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function fogRevealAll() { fog.fill(255); }
|
function fogRevealAll() { fog.fill(255); renderDirty.fill(1); }
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ function heatPass() {
|
|||||||
function heatChunk(c, sc) {
|
function heatChunk(c, sc) {
|
||||||
const cx = c % CW, cy = (c / CW) | 0;
|
const cx = c % CW, cy = (c / CW) | 0;
|
||||||
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
|
const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
|
||||||
|
renderDirty[c] = 1;
|
||||||
|
if (cx > 0) renderDirty[c - 1] = 1;
|
||||||
|
if (cx + 1 < CW) renderDirty[c + 1] = 1;
|
||||||
|
if (cy > 0) renderDirty[c - CW] = 1;
|
||||||
|
if (cy + 1 < CH) renderDirty[c + CW] = 1;
|
||||||
roofDirty[cx] = 1;
|
roofDirty[cx] = 1;
|
||||||
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
|
const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW];
|
||||||
let maxDev = 0, burning = false;
|
let maxDev = 0, burning = false;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function netSat(i) { if (POWER_FREE) return 1; const n = nets[netId[i]]; return
|
|||||||
// Move loose grains out of the given cells, to the nearest open cell above
|
// Move loose grains out of the given cells, to the nearest open cell above
|
||||||
// the footprint in the same column (they then fall back on top). Gas just vanishes.
|
// the footprint in the same column (they then fall back on top). Gas just vanishes.
|
||||||
function evictCells(cells, topY) {
|
function evictCells(cells, topY) {
|
||||||
for (const i of cells) bgm[i] = 0;
|
for (const i of cells) { bgm[i] = 0; renderDirtyIdx(i); }
|
||||||
for (const i of cells) {
|
for (const i of cells) {
|
||||||
const m = mat[i], ph = PH[m];
|
const m = mat[i], ph = PH[m];
|
||||||
if (m === EMPTY || ph === PH_SOLID) continue;
|
if (m === EMPTY || ph === PH_SOLID) continue;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// no longer reaches the ground comes down: every trunk cell drops as wood chunks.
|
// no longer reaches the ground comes down: every trunk cell drops as wood chunks.
|
||||||
function cutTree(i, x, y, tier, amt = 1) {
|
function cutTree(i, x, y, tier, amt = 1) {
|
||||||
const b = bgm[i];
|
const b = bgm[i];
|
||||||
|
renderDirtyIdx(i);
|
||||||
const p = digp[i] + amt * tier.speed * 70 / (1 + HARD[b]);
|
const p = digp[i] + amt * tier.speed * 70 / (1 + HARD[b]);
|
||||||
if (p < 255) { digp[i] = p; return false; }
|
if (p < 255) { digp[i] = p; return false; }
|
||||||
digp[i] = 0; bgm[i] = 0;
|
digp[i] = 0; bgm[i] = 0;
|
||||||
@@ -104,6 +105,7 @@ function plantTick() {
|
|||||||
if (y <= 6 || y >= H - 1) continue;
|
if (y <= 6 || y >= H - 1) continue;
|
||||||
const i = y * W + x;
|
const i = y * W + x;
|
||||||
if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue;
|
if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue;
|
||||||
|
renderDirtyIdx(i);
|
||||||
if (!SOIL[mat[i + W]] || temp[i] < 2 || temp[i] > 45) { life[i] = 0; continue; }
|
if (!SOIL[mat[i + W]] || temp[i] < 2 || temp[i] > 45) { life[i] = 0; continue; }
|
||||||
let room = true;
|
let room = true;
|
||||||
for (let k = 1; k <= 6 && room; k++) { const j = i - k * W; if (PH[mat[j]] !== PH_EMPTY && PH[mat[j]] !== PH_GAS || bgm[j] === WOOD) room = false; }
|
for (let k = 1; k <= 6 && room; k++) { const j = i - k * W; if (PH[mat[j]] !== PH_EMPTY && PH[mat[j]] !== PH_GAS || bgm[j] === WOOD) room = false; }
|
||||||
@@ -120,7 +122,7 @@ function setTreeCell(x, y, m) {
|
|||||||
const i = y * W + x;
|
const i = y * W + x;
|
||||||
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return;
|
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return;
|
||||||
if (m === LEAVES && bgm[i]) return;
|
if (m === LEAVES && bgm[i]) return;
|
||||||
bgm[i] = m;
|
bgm[i] = m; renderDirtyIdx(i);
|
||||||
if (treeDraw) treeDraw.push(i);
|
if (treeDraw) treeDraw.push(i);
|
||||||
}
|
}
|
||||||
function saplingTick() {
|
function saplingTick() {
|
||||||
@@ -134,7 +136,7 @@ function saplingTick() {
|
|||||||
sp.age++;
|
sp.age++;
|
||||||
const s = Math.min(1, sp.age / 180);
|
const s = Math.min(1, sp.age / 180);
|
||||||
// wipe the previous stage, then draw this one
|
// wipe the previous stage, then draw this one
|
||||||
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) bgm[i] = 0;
|
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) { bgm[i] = 0; renderDirtyIdx(i); }
|
||||||
treeDraw = sp.cells = [];
|
treeDraw = sp.cells = [];
|
||||||
const th = Math.max(1, Math.round(sp.th * s)), wide = s > 0.5 ? 2 : 1;
|
const th = Math.max(1, Math.round(sp.th * s)), wide = s > 0.5 ? 2 : 1;
|
||||||
for (let j = 1; j <= th; j++) for (let dx = 0; dx < wide; dx++) setTreeCell(sp.x + dx, sp.y - j, WOOD);
|
for (let j = 1; j <= th; j++) for (let dx = 0; dx < wide; dx++) setTreeCell(sp.x + dx, sp.y - j, WOOD);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function toolDig(cx, cy, r) {
|
|||||||
if (isWall(b) && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) removeBlock((x / BS) | 0, (y / BS) | 0);
|
if (isWall(b) && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) removeBlock((x / BS) | 0, (y / BS) | 0);
|
||||||
dug++;
|
dug++;
|
||||||
if ((dug & 3) === 0) emit({ t: 'debris', x, y, m });
|
if ((dug & 3) === 0) emit({ t: 'debris', x, y, m });
|
||||||
} else digp[i] = p;
|
} else { digp[i] = p; renderDirtyIdx(i); }
|
||||||
});
|
});
|
||||||
stats.dug += dug;
|
stats.dug += dug;
|
||||||
let status = '';
|
let status = '';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ let ents = []; // loose objects (bars)
|
|||||||
let bgm; // background layer: trees. Liquids, powders and gases pass in front of it.
|
let bgm; // background layer: trees. Liquids, powders and gases pass in front of it.
|
||||||
let flyers = []; // grains in flight, blown out of the bot
|
let flyers = []; // grains in flight, blown out of the bot
|
||||||
let ghostBlocks = new Map(), ghostMolds = [];
|
let ghostBlocks = new Map(), ghostMolds = [];
|
||||||
|
let renderDirty; // GPU world textures, one invalidation bit per 32x32 chunk.
|
||||||
let awake, hot, hotLvl, powderArea, roofDirty;
|
let awake, hot, hotLvl, powderArea, roofDirty;
|
||||||
let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0;
|
let loadWakeOrder = null, loadWakePending = null, loadWakeIndex = 0;
|
||||||
let tick = 0, tickStamp = 1;
|
let tick = 0, tickStamp = 1;
|
||||||
@@ -53,6 +54,7 @@ function alloc(w, h) {
|
|||||||
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
|
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
|
||||||
AMB = new Float32Array(h);
|
AMB = new Float32Array(h);
|
||||||
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
|
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
|
||||||
|
renderDirty = new Uint8Array(NCH).fill(1);
|
||||||
cleatChunks = new Uint8Array(NCH);
|
cleatChunks = new Uint8Array(NCH);
|
||||||
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
|
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
|
||||||
roofDirty = new Uint8Array(CW).fill(1);
|
roofDirty = new Uint8Array(CW).fill(1);
|
||||||
@@ -90,7 +92,10 @@ function fillNoise() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Waking ----------
|
// ---------- Waking ----------
|
||||||
|
function renderDirtyXY(x, y) { renderDirty[(y >> 5) * CW + (x >> 5)] = 1; }
|
||||||
|
function renderDirtyIdx(i) { renderDirtyXY(i % W, (i / W) | 0); }
|
||||||
function wakeXY(x, y) {
|
function wakeXY(x, y) {
|
||||||
|
renderDirtyXY(x, y);
|
||||||
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
|
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
|
||||||
roofDirty[cx] = 1;
|
roofDirty[cx] = 1;
|
||||||
awake[c] = 3;
|
awake[c] = 3;
|
||||||
@@ -99,6 +104,7 @@ function wakeXY(x, y) {
|
|||||||
if (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3;
|
if (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3;
|
||||||
}
|
}
|
||||||
function wakeHeatXY(x, y) {
|
function wakeHeatXY(x, y) {
|
||||||
|
renderDirtyXY(x, y);
|
||||||
const c = (y >> 5) * CW + (x >> 5);
|
const c = (y >> 5) * CW + (x >> 5);
|
||||||
roofDirty[x >> 5] = 1;
|
roofDirty[x >> 5] = 1;
|
||||||
hot[c] = 6; hotLvl[c] = 2;
|
hot[c] = 6; hotLvl[c] = 2;
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ async function openNewClaim() {
|
|||||||
showLoading(true, 0.9, 'Saving');
|
showLoading(true, 0.9, 'Saving');
|
||||||
if (!await saveWorld('auto', true)) throw new Error('Save failed');
|
if (!await saveWorld('auto', true)) throw new Error('Save failed');
|
||||||
showLoading(true, 1, 'Saving');
|
showLoading(true, 1, 'Saving');
|
||||||
showLoading(false); closeAllModals(); enterGame();
|
await gpuWarmWorld();
|
||||||
|
showLoading(false); closeAllModals(); enterGame(true);
|
||||||
showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 });
|
showCard({ kind: 'New claim', title: name, body: [`Claim ${n}. You owe ${fmtMoney(debt)} on this claim. Your money, bag, upgrades and what you’ve learned came along. Buildings stayed on the old claim.`], hold: 1.8 });
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ function scanVisible() {
|
|||||||
}
|
}
|
||||||
let ambientT = 0;
|
let ambientT = 0;
|
||||||
function frame(now) {
|
function frame(now) {
|
||||||
|
if (GPU.stopped) return;
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
|
if (GPU.recovering) { lastT = now; return; }
|
||||||
const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now;
|
const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now;
|
||||||
if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return;
|
if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return;
|
||||||
const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now;
|
const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now;
|
||||||
@@ -58,19 +60,12 @@ function frame(now) {
|
|||||||
if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY);
|
if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY);
|
||||||
{ const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); }
|
{ const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); }
|
||||||
R.frame++;
|
R.frame++;
|
||||||
if (SET.showPerf) perfLast.computeLight = 0;
|
|
||||||
drawWorld();
|
drawWorld();
|
||||||
if (SET.showPerf) perfTimes.computeLight = (perfTimes.computeLight || 0) * 0.9 + perfLast.computeLight * 0.1;
|
|
||||||
const overlayStart = SET.showPerf ? performance.now() : 0;
|
const overlayStart = SET.showPerf ? performance.now() : 0;
|
||||||
drawCracks();
|
|
||||||
if (!weather.seen && (R.rain || 0) > 0.12) {
|
if (!weather.seen && (R.rain || 0) > 0.12) {
|
||||||
weather.seen = true;
|
weather.seen = true;
|
||||||
showCard({ kind: 'Weather', title: 'Rain', swatch: WATER, body: ['Steam that rises into the open sky gathers into clouds and comes back down as rain nearby. On freezing heights it falls as snow.', 'Water is never used up, only moved around. If some leaves the world (dropped into the dock, say), a shower from beyond the claim makes it up.'] });
|
showCard({ kind: 'Weather', title: 'Rain', swatch: WATER, body: ['Steam that rises into the open sky gathers into clouds and comes back down as rain nearby. On freezing heights it falls as snow.', 'Water is never used up, only moved around. If some leaves the world (dropped into the dock, say), a shower from beyond the claim makes it up.'] });
|
||||||
}
|
}
|
||||||
if (SET.look === 'smooth' && !R.gpuOn && SM.tried && !SM.ok) {
|
|
||||||
SET.look = 'pixel'; saveSettings();
|
|
||||||
toast(SM.why === 'slow' ? 'The smooth look was too slow on this computer, so the pixel look is on. You can try it again in Settings.' : SM.why === 'software' ? 'This browser is drawing without the graphics card, so the smooth look would crawl. The pixel look is on.' : 'This browser cannot draw the smooth look, so the pixel look is on.');
|
|
||||||
}
|
|
||||||
drawMachines();
|
drawMachines();
|
||||||
drawMolds(moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)));
|
drawMolds(moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)));
|
||||||
drawParticles();
|
drawParticles();
|
||||||
@@ -101,7 +96,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 ${R.gpuOn ? "" : `· 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)} ms (light ${(GPU.lightMs || 0).toFixed(1)})` : ""} · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
|
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick (move ${(perfTimes.simMove || 0).toFixed(1)}, heat ${(perfTimes.heatPass || 0).toFixed(1)}, rest ${Math.max(0, simMs - (perfTimes.simMove || 0) - (perfTimes.heatPass || 0)).toFixed(1)})\nworld ${(perfTimes.drawWorld || 0).toFixed(1)} ms · overrides ${(perfMarks.override || 0).toFixed(1)} · upload ${(perfMarks.upload || 0).toFixed(1)} · GPU ${GPU.gpuMs === null ? "—" : GPU.gpuMs.toFixed(1)} ms (light ${(GPU.lightMs || 0).toFixed(1)}) · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Browser-only clocks; simulation code stays usable without a browser or a clock.
|
// Browser-only clocks; simulation code stays usable without a browser or a clock.
|
||||||
let PERF_MARKS = false, PERF_FRAME = null;
|
let PERF_MARKS = false, PERF_FRAME = null;
|
||||||
const perfMarks = {}, perfTimes = {}, perfLast = {};
|
const perfMarks = {}, perfTimes = {}, perfLast = {};
|
||||||
for (const name of ['drawWorld', 'computeLight', 'simMove', 'heatPass']) {
|
for (const name of ['drawWorld', 'simMove', 'heatPass']) {
|
||||||
const original = window[name];
|
const original = window[name];
|
||||||
window[name] = function (...args) {
|
window[name] = function (...args) {
|
||||||
if (!SET.showPerf && !PERF_MARKS) return original.apply(this, args);
|
if (!SET.showPerf && !PERF_MARKS) return original.apply(this, args);
|
||||||
@@ -9,7 +9,7 @@ for (const name of ['drawWorld', 'computeLight', 'simMove', 'heatPass']) {
|
|||||||
const result = original.apply(this, args);
|
const result = original.apply(this, args);
|
||||||
const ms = performance.now() - start;
|
const ms = performance.now() - start;
|
||||||
perfLast[name] = ms;
|
perfLast[name] = ms;
|
||||||
if (name !== 'computeLight') perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1;
|
perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1;
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,4 @@
|
|||||||
// Drill previews: shaken rock cracks, pulse wavefronts and aiming fan.
|
// Drill pulse wavefronts and aiming fan.
|
||||||
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking.
|
|
||||||
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines
|
|
||||||
// run on unbroken from one cell to the next and look like real fractures, not a pattern.
|
|
||||||
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))];
|
|
||||||
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }];
|
|
||||||
function crackSeg(ctx, x, y, n, sc, zp, oy) {
|
|
||||||
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61);
|
|
||||||
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1);
|
|
||||||
const pts = [];
|
|
||||||
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y);
|
|
||||||
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c));
|
|
||||||
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1);
|
|
||||||
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d));
|
|
||||||
for (let k = 0; k + 3 < pts.length; k += 4) {
|
|
||||||
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy);
|
|
||||||
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function drawCracks() {
|
|
||||||
const ctx = R.ctx, zp = R.zp;
|
|
||||||
if (zp < 2.5) return;
|
|
||||||
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY));
|
|
||||||
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp));
|
|
||||||
const cells = [];
|
|
||||||
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } }
|
|
||||||
if (!cells.length) return;
|
|
||||||
ctx.save(); ctx.lineCap = 'round';
|
|
||||||
for (let l = 0; l < CRACK_LV.length; l++) {
|
|
||||||
const L = CRACK_LV[l];
|
|
||||||
// a soft lit lip just below each crack, then the dark crack itself
|
|
||||||
for (const pass of [0, 1]) {
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
|
|
||||||
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
|
|
||||||
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
|
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
|
||||||
function pulseRGB(tune) {
|
function pulseRGB(tune) {
|
||||||
if (!tune) return '150,215,255';
|
if (!tune) return '150,215,255';
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ function showLoading(on, p, label, title) {
|
|||||||
$('loading').hidden = !on;
|
$('loading').hidden = !on;
|
||||||
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
|
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
|
||||||
}
|
}
|
||||||
function enterGame() {
|
function enterGame(renderReady = false) {
|
||||||
closeDevConsole(); $('dev-output').textContent = ''; $('dev-input').value = '';
|
closeDevConsole(); $('dev-output').textContent = ''; $('dev-input').value = '';
|
||||||
$('cards').innerHTML = '';
|
$('cards').innerHTML = '';
|
||||||
if (R.floaters) R.floaters.length = 0; R.particles.length = 0;
|
if (R.floaters) R.floaters.length = 0; R.particles.length = 0;
|
||||||
GAME.status = ''; GAME.statusT = 0;
|
GAME.status = ''; GAME.statusT = 0;
|
||||||
$('title').hidden = true; $('hud').hidden = false;
|
$('title').hidden = true; $('hud').hidden = false;
|
||||||
GAME.running = true; GAME.paused = false;
|
GAME.running = true; GAME.paused = false;
|
||||||
buildSky(); resizeRender(); snapCamera(); resetMap();
|
if (!renderReady) { buildSky(); resizeRender(); snapCamera(); }
|
||||||
|
resetMap();
|
||||||
GAME.lastAutosave = performance.now();
|
GAME.lastAutosave = performance.now();
|
||||||
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
|
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
|
||||||
hudDirty = true; renderHotbar();
|
hudDirty = true; renderHotbar();
|
||||||
@@ -41,7 +42,8 @@ async function startNewWorld(seed, w, h, name = '') {
|
|||||||
showLoading(true, 0.9, 'Saving');
|
showLoading(true, 0.9, 'Saving');
|
||||||
await saveWorld('auto');
|
await saveWorld('auto');
|
||||||
showLoading(true, 1, 'Saving');
|
showLoading(true, 1, 'Saving');
|
||||||
showLoading(false); closeAllModals(); enterGame();
|
await gpuWarmWorld();
|
||||||
|
showLoading(false); closeAllModals(); enterGame(true);
|
||||||
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
|
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Pneumatic nozzle (2) picks up and blows the selection. Click or Q/E in the bag selects; Ctrl+click mixes. Hold Ctrl to see everything found. Clear returns to picking what you click. Shift: everything.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
|
||||||
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
|
} catch (e) { showLoading(false); toast('Could not create a world. Browser storage is unavailable.'); }
|
||||||
}
|
}
|
||||||
@@ -67,8 +69,8 @@ async function startFromSave(sv, onProgress) {
|
|||||||
GAME.drag = null; GAME.blow.clear();
|
GAME.drag = null; GAME.blow.clear();
|
||||||
onProgress(0.95, 'Your gear and notebook');
|
onProgress(0.95, 'Your gear and notebook');
|
||||||
await loadCareer();
|
await loadCareer();
|
||||||
showLoading(false);
|
await gpuWarmWorld();
|
||||||
enterGame();
|
showLoading(false); enterGame(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
async function loadSavedWorld(id) {
|
async function loadSavedWorld(id) {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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 draws both looks on the graphics card. Legacy 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']] },
|
||||||
@@ -98,8 +97,6 @@ function renderSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function applySettings() {
|
function applySettings() {
|
||||||
// choosing Smooth again after it was too slow: try once more, at half resolution
|
|
||||||
if (SET.look === 'smooth' && SM.tried && !SM.ok && SM.why === 'slow' && SM.gl) { SM.ok = true; SM.why = ''; SM.scale = 0.5; SM.n = 0; }
|
|
||||||
saveSettings();
|
saveSettings();
|
||||||
document.documentElement.style.setProperty('--ui', SET.uiScale);
|
document.documentElement.style.setProperty('--ui', SET.uiScale);
|
||||||
if (R.canvas) { resizeRender(); R.snapNext = true; }
|
if (R.canvas) { resizeRender(); R.snapNext = true; }
|
||||||
|
|||||||
@@ -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,
|
||||||
renderer: 'webgpu', look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true,
|
look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true,
|
||||||
minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3,
|
minimap: true, minimapZoom: 4, fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3,
|
||||||
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,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
// Startup capabilities and title art.
|
// Startup capabilities and title art.
|
||||||
const REQUIRE_GPU_AND_THREADS = false;
|
const REQUIRE_WEBGPU = true;
|
||||||
|
const REQUIRE_THREADS = false;
|
||||||
const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false };
|
const CAPS = { isolated: self.crossOriginIsolated === true, webgpu: false, adapterInfo: null, ready: false };
|
||||||
async function checkCapabilities() {
|
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 && gpuLimitsOK(adapter) && await gpuInit(adapter);
|
||||||
if (adapter) await gpuInit(adapter);
|
CAPS.requiredLimits = GPU_LIMITS;
|
||||||
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; SET.renderer = 'legacy'; }
|
} catch (e) { CAPS.webgpu = false; }
|
||||||
CAPS.ready = true;
|
CAPS.ready = true;
|
||||||
renderCapabilityNotice();
|
renderCapabilityNotice();
|
||||||
}
|
}
|
||||||
@@ -19,13 +19,14 @@ function renderCapabilityNotice() {
|
|||||||
if (!CAPS.webgpu) messages.push(/Firefox\//.test(navigator.userAgent)
|
if (!CAPS.webgpu) messages.push(/Firefox\//.test(navigator.userAgent)
|
||||||
? 'WebGPU is unavailable. In Firefox, open about:config and set dom.webgpu.enabled to true, then restart. On Linux, use Firefox Nightly. If no graphics adapter is available, try Chrome or Edge.'
|
? 'WebGPU is unavailable. In Firefox, open about:config and set dom.webgpu.enabled to true, then restart. On Linux, use Firefox Nightly. If no graphics adapter is available, try Chrome or Edge.'
|
||||||
: 'WebGPU is unavailable. Use a browser with WebGPU (Chrome or Edge).');
|
: 'WebGPU is unavailable. Use a browser with WebGPU (Chrome or Edge).');
|
||||||
|
if (GPU.error) messages.push('WebGPU could not start: ' + GPU.error);
|
||||||
const notice = $('cap-notice');
|
const notice = $('cap-notice');
|
||||||
notice.replaceChildren(...messages.map(text => { const p = document.createElement('p'); p.textContent = text; return p; }));
|
notice.replaceChildren(...messages.map(text => { const p = document.createElement('p'); p.textContent = text; return p; }));
|
||||||
notice.hidden = !messages.length;
|
notice.hidden = !messages.length;
|
||||||
}
|
}
|
||||||
async function canStartGame() {
|
async function canStartGame() {
|
||||||
await CAPS_CHECK;
|
await CAPS_CHECK;
|
||||||
return !REQUIRE_GPU_AND_THREADS || (CAPS.isolated && CAPS.webgpu);
|
return (!REQUIRE_WEBGPU || (CAPS.webgpu && GPU.ok)) && (!REQUIRE_THREADS || CAPS.isolated);
|
||||||
}
|
}
|
||||||
const CAPS_CHECK = checkCapabilities();
|
const CAPS_CHECK = checkCapabilities();
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ with launch(browser_name='firefox') as (_, page, url):
|
|||||||
text = page.inner_text('#cap-notice')
|
text = page.inner_text('#cap-notice')
|
||||||
assert 'about:config' in text and 'dom.webgpu.enabled' in text and 'Firefox Nightly' in text
|
assert 'about:config' in text and 'dom.webgpu.enabled' in text and 'Firefox Nightly' in text
|
||||||
print('PASS Firefox isolation and capability notice')
|
print('PASS Firefox isolation and capability notice')
|
||||||
page.click('#btn-new')
|
if not caps['webgpu']:
|
||||||
page.fill('#nw-seed', '424242')
|
assert not page.evaluate('canStartGame()')
|
||||||
page.select_option('#nw-size', '2048x1024')
|
assert not page.evaluate('startNewWorld(424242,512,384)')
|
||||||
page.click('#btn-generate')
|
assert not page.evaluate('GAME.running')
|
||||||
|
print('PASS Firefox without WebGPU cannot start a world')
|
||||||
|
else:
|
||||||
|
page.evaluate('startNewWorld(424242,512,384)')
|
||||||
page.wait_for_function('GAME.running && !GAME.loading',timeout=60000)
|
page.wait_for_function('GAME.running && !GAME.loading',timeout=60000)
|
||||||
before = page.evaluate('tick')
|
before=page.evaluate('tick');page.wait_for_timeout(3000)
|
||||||
page.wait_for_timeout(3000)
|
|
||||||
assert page.evaluate('tick')>before
|
assert page.evaluate('tick')>before
|
||||||
assert not errors,errors
|
assert not errors,errors
|
||||||
print('PASS Firefox new world runs for three seconds without page errors')
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -1,19 +1,6 @@
|
|||||||
// Run the renderer's lighting against real simulation data, with only canvas storage stubbed.
|
// Lighting behaviour checks executed by the GPU browser harness.
|
||||||
const fs = require('fs');
|
await flatWorld(run, 320, 200, 160);
|
||||||
const path = require('path');
|
await run(`
|
||||||
const { load, suite, flatWorld } = require('./harness');
|
|
||||||
const { run } = load();
|
|
||||||
const { check, done } = suite();
|
|
||||||
for (const f of ['setup.js', 'light.js']) run(fs.readFileSync(path.join(__dirname, '../src/render', f), 'utf8'));
|
|
||||||
run(`
|
|
||||||
const SET = { lighting: 'full', daylight: 1, ambient: 0.12 };
|
|
||||||
R.lightC = {};
|
|
||||||
R.lightX = { createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), putImageData() {} };
|
|
||||||
R.vx0 = 0; R.vy0 = 100; R.bw = 320; R.bh = 80;
|
|
||||||
function lightAt(a, x, y) { return a[(Math.floor(y / R.lres) - R.ly0) * R.lw + Math.floor(x / R.lres) - R.lx0]; }
|
|
||||||
`);
|
|
||||||
flatWorld(run, 320, 200, 160);
|
|
||||||
run(`
|
|
||||||
bot.lamp = false;
|
bot.lamp = false;
|
||||||
for (let bx = 8; bx < 30; bx++) placeBlock(BP_WALL, RSTONE, bx, 20, true);
|
for (let bx = 8; bx < 30; bx++) placeBlock(BP_WALL, RSTONE, bx, 20, true);
|
||||||
for (let by = 21; by < 39; by++) {
|
for (let by = 21; by < 39; by++) {
|
||||||
@@ -26,43 +13,43 @@ run(`
|
|||||||
addMold('bit', 24, 30, RSTONE, true);
|
addMold('bit', 24, 30, RSTONE, true);
|
||||||
for (let y = 80; y < 88; y++) for (let x = 180; x < 280; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);
|
for (let y = 80; y < 88; y++) for (let x = 180; x < 280; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);
|
||||||
updateRoof();
|
updateRoof();
|
||||||
const simRoof = Array.from(roofY);
|
window.simRoof = Array.from(roofY);
|
||||||
`);
|
`);
|
||||||
for (const mode of ['full', 'fast']) {
|
for (const mode of ['full', 'fast']) {
|
||||||
run(`SET.lighting = '${mode}'; computeLight();`);
|
await run(`SET.lighting = '${mode}'; computeLight();`);
|
||||||
for (const channel of ['lr', 'lg']) {
|
for (const channel of ['lr', 'lg']) {
|
||||||
const [inside, open, rock, ground, openGround] = run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`);
|
const [inside, open, rock, ground, openGround] = await run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`);
|
||||||
check(`${mode} ${channel}: under a built roof matches open daylight`, Math.abs(inside / open - 1) < 0.03, `${inside} / ${open}`);
|
check(`${mode} ${channel}: under a built roof matches open daylight`, Math.abs(inside / open - 1) < 0.03, `${inside} / ${open}`);
|
||||||
check(`${mode} ${channel}: ground under the base matches outside`, Math.abs(ground / openGround - 1) < 0.03, `${ground} / ${openGround}`);
|
check(`${mode} ${channel}: ground under the base matches outside`, Math.abs(ground / openGround - 1) < 0.03, `${ground} / ${openGround}`);
|
||||||
check(`${mode} ${channel}: natural overhang still shades`, rock < open * 0.95, `${rock} / ${open}`);
|
check(`${mode} ${channel}: natural overhang still shades`, rock < open * 0.95, `${rock} / ${open}`);
|
||||||
}
|
}
|
||||||
// Directly check transmission as daylight can hide an opaque piece in the open.
|
// Directly check transmission as daylight can hide an opaque piece in the open.
|
||||||
for (const [name, x, y] of [['wall', 32, 120], ['belt', 48, 120], ['screw', 56, 120], ['machine', 72, 120], ['mold', 96, 120]]) {
|
for (const [name, x, y] of [['wall', 32, 120], ['belt', 48, 120], ['screw', 56, 120], ['machine', 72, 120], ['mold', 96, 120]]) {
|
||||||
const a = run(`lightAt(R.latt, ${x}, ${y})`);
|
const a = await run(`lightAt(R.latt, ${x}, ${y})`);
|
||||||
check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a);
|
check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-3, a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check('lighting leaves the simulation roof unchanged', run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80'));
|
check('lighting leaves the simulation roof unchanged', await run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80'));
|
||||||
for (const material of ['STONE', 'SAND', 'WATER']) {
|
for (const material of ['STONE', 'SAND', 'WATER']) {
|
||||||
run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); setCell(97 * W + 80, STONE, AMB[97], F_NAT); updateRoof(); computeLight();`);
|
await run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); setCell(97 * W + 80, STONE, AMB[97], F_NAT); updateRoof(); computeLight();`);
|
||||||
check(`${material} above the view still blocks daylight`, run('lightAt(R.lr, 80, 144) < 0.95'));
|
check(`${material} above the view still blocks daylight`, await run('lightAt(R.lr, 80, 144) < 0.925'));
|
||||||
run('setCell(96 * W + 80, EMPTY, AMB[96], 0); setCell(97 * W + 80, EMPTY, AMB[97], 0);');
|
await run('setCell(96 * W + 80, EMPTY, AMB[96], 0); setCell(97 * W + 80, EMPTY, AMB[97], 0);');
|
||||||
}
|
}
|
||||||
run('updateRoof(); computeLight();');
|
await run('updateRoof(); computeLight();');
|
||||||
check('removing an obstruction restores daylight', run('lightAt(R.lr, 80, 144) === 1'));
|
check('removing an obstruction restores daylight', await run('lightAt(R.lr, 80, 144) === 1'));
|
||||||
for (const field of ['blockP[24 * BW + 20]', 'mach[96 * W + 80]', 'part[96 * W + 80]']) {
|
for (const field of ['blockP[24 * BW + 20]', 'mach[96 * W + 80]', 'part[96 * W + 80]']) {
|
||||||
run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`);
|
await run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`);
|
||||||
check(`${field}: built contents do not shade or absorb light`, run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-6'));
|
check(`${field}: built contents do not shade or absorb light`, await run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-3'));
|
||||||
run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`);
|
await run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`);
|
||||||
}
|
}
|
||||||
run('updateRoof();');
|
await run('updateRoof();');
|
||||||
run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`);
|
await run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`);
|
||||||
check('hot material inside the base lights its surroundings', run('lightAt(R.lr, 88, 140) > 0.2'));
|
check('hot material inside the base lights its surroundings', await run('lightAt(R.lr, 88, 140) > 0.2'));
|
||||||
run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`);
|
await run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`);
|
||||||
check('the bot lamp still lights the base', run('lightAt(R.lr, 80, 140) > 0.2'));
|
check('the bot lamp still lights the base', await run('lightAt(R.lr, 80, 140) > 0.2'));
|
||||||
|
|
||||||
flatWorld(run, 320, 200, 160);
|
await flatWorld(run, 320, 200, 160);
|
||||||
run(`
|
await run(`
|
||||||
SET.daylight = 1; bot.lamp = false;
|
SET.daylight = 1; bot.lamp = false;
|
||||||
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 20, true);
|
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 20, true);
|
||||||
for (let x = 32; x < 120; x++) for (let y = 77; y < 80; y++) setCell(y * W + x, SAND, AMB[y], 0);
|
for (let x = 32; x < 120; x++) for (let y = 77; y < 80; y++) setCell(y * W + x, SAND, AMB[y], 0);
|
||||||
@@ -74,19 +61,20 @@ run(`
|
|||||||
updateRoof();
|
updateRoof();
|
||||||
`);
|
`);
|
||||||
for (const mode of ['full', 'fast']) {
|
for (const mode of ['full', 'fast']) {
|
||||||
run(`SET.lighting = '${mode}'; computeLight();`);
|
await run(`SET.lighting = '${mode}'; computeLight();`);
|
||||||
for (const channel of ['lr', 'lg', 'lb']) {
|
for (const channel of ['lr', 'lg', 'lb']) {
|
||||||
const [belt, open, stone] = run(`[lightAt(R.${channel}, 80, 84), lightAt(R.${channel}, 160, 84), lightAt(R.${channel}, 232, 84)]`);
|
const [belt, open, stone] = await run(`[lightAt(R.${channel}, 80, 84), lightAt(R.${channel}, 160, 84), lightAt(R.${channel}, 232, 84)]`);
|
||||||
check(`${mode} ${channel}: a loaded belt leaves daylight below it`, Math.abs(belt - open) < 1e-6, `${belt} / ${open}`);
|
check(`${mode} ${channel}: a loaded belt leaves daylight below it`, Math.abs(belt - open) < 1e-3, `${belt} / ${open}`);
|
||||||
check(`${mode} ${channel}: sand on natural stone still shades below`, stone < open * 0.95, `${stone} / ${open}`);
|
check(`${mode} ${channel}: sand on natural stone still shades below`, stone < open * 0.95, `${stone} / ${open}`);
|
||||||
const [streamGround, openGround] = run(`[lightAt(R.${channel}, 152, 160), lightAt(R.${channel}, 160, 160)]`);
|
const [streamGround, openGround] = await run(`[lightAt(R.${channel}, 152, 160), lightAt(R.${channel}, 160, 160)]`);
|
||||||
check(`${mode} ${channel}: a falling stream leaves ground as bright as open sky`, Math.abs(streamGround - openGround) < 1e-6, `${streamGround} / ${openGround}`);
|
check(`${mode} ${channel}: a falling stream leaves ground as bright as open sky`, Math.abs(streamGround - openGround) < 1e-3, `${streamGround} / ${openGround}`);
|
||||||
}
|
}
|
||||||
run('setCell(76 * W + 80, WATER, AMB[76], 0); updateRoof(); computeLight();');
|
await run('setCell(76 * W + 80, WATER, AMB[76], 0); updateRoof(); computeLight();');
|
||||||
const a = run('lightAt(R.latt, 80, 76)');
|
const a = await run('lightAt(R.latt, 80, 76)');
|
||||||
check(`${mode}: a wet belt load transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a);
|
check(`${mode}: a wet belt load transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-3, a);
|
||||||
run('setCell(76 * W + 80, EMPTY, AMB[76], 0); updateRoof();');
|
await run('setCell(76 * W + 80, EMPTY, AMB[76], 0); updateRoof();');
|
||||||
}
|
}
|
||||||
|
await run('R.camY=0;');
|
||||||
for (const [name, support, depth, gap, expected] of [
|
for (const [name, support, depth, gap, expected] of [
|
||||||
['left belt', 'mat[i] = BELTL', 12, '', true],
|
['left belt', 'mat[i] = BELTL', 12, '', true],
|
||||||
['right belt', 'mat[i] = BELTR', 12, '', true],
|
['right belt', 'mat[i] = BELTR', 12, '', true],
|
||||||
@@ -102,28 +90,29 @@ for (const [name, support, depth, gap, expected] of [
|
|||||||
['screw', 'part[i] = P_SCREW_R', 3, '', true],
|
['screw', 'part[i] = P_SCREW_R', 3, '', true],
|
||||||
['loaded screw', 'mat[i] = SAND; part[i] = P_SCREW_R', 3, '', true],
|
['loaded screw', 'mat[i] = SAND; part[i] = P_SCREW_R', 3, '', true],
|
||||||
]) {
|
]) {
|
||||||
run(`
|
await run(`
|
||||||
for (let y = 40; y <= 53; y++) { const i = y * W + 300; mat[i] = SAND; part[i] = 0; flags[i] = 0; }
|
for (let y = 40; y <= 53; y++) { const i = y * W + 300; mat[i] = SAND; part[i] = 0; flags[i] = 0; }
|
||||||
{ const i = (40 + ${depth}) * W + 300; mat[i] = EMPTY; mat[i + W] = BELTR; ${support}; }
|
{ const i = (40 + ${depth}) * W + 300; mat[i] = EMPTY; mat[i + W] = BELTR; ${support}; }
|
||||||
${gap ? `mat[41 * W + 300] = ${gap};` : ''}
|
${gap ? `mat[41 * W + 300] = ${gap};` : ''}
|
||||||
`);
|
`);
|
||||||
check(`${name}: loose cell detection`, run('looseForLight(40 * W + 300)') === expected);
|
check(`${name}: loose cell detection`, await run('lightAt(R.latt,300,40) > 0.9') === expected);
|
||||||
}
|
}
|
||||||
run('mat[40 * W + 300] = SAND; mat[41 * W + 300] = EMPTY;');
|
await run('mat[40 * W + 300] = SAND; mat[41 * W + 300] = EMPTY;');
|
||||||
check('a single grain in mid-air is see-through', run('looseForLight(40 * W + 300)'));
|
check('a single grain in mid-air is see-through', await run('lightAt(R.latt,300,40) > 0.9'));
|
||||||
run('mat[(H - 1) * W + 300] = SAND;');
|
await run('R.camY=120;mat[(H - 1) * W + 300] = SAND;');
|
||||||
check('loose cell detection stops at the world bottom', run('!looseForLight((H - 1) * W + 300)'));
|
check('loose cell detection stops at the world bottom', await run('lightAt(R.latt,300,H-1) < 0.92'));
|
||||||
|
|
||||||
flatWorld(run, 320, 200, 160);
|
await flatWorld(run, 320, 200, 160);
|
||||||
run(`
|
await run('R.camY=100;');
|
||||||
|
await run(`
|
||||||
bot.lamp = false; stats.refined = 0;
|
bot.lamp = false; stats.refined = 0;
|
||||||
placeBlock(BP_GRIND, 0, 20, 20, true);
|
placeBlock(BP_GRIND, 0, 20, 20, true);
|
||||||
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 36, true);
|
for (let bx = 8; bx < 30; bx++) placeBlock(BP_BELT, 1, bx, 36, true);
|
||||||
setCell(79 * W + 80, SAND, AMB[79], 0);
|
setCell(79 * W + 80, SAND, AMB[79], 0);
|
||||||
`);
|
`);
|
||||||
check('sand resting on grinder housing is see-through', run('looseForLight(79 * W + 80)'));
|
check('sand resting on grinder housing is see-through', await run('lightAt(R.latt,80,79) > 0.9'));
|
||||||
for (const mode of ['full', 'fast']) {
|
for (const mode of ['full', 'fast']) {
|
||||||
const result = run(`(() => {
|
const result = await run(`(() => {
|
||||||
SET.lighting = '${mode}';
|
SET.lighting = '${mode}';
|
||||||
let mismatches = 0, first = '';
|
let mismatches = 0, first = '';
|
||||||
for (let t = 0; t < 180; t++) {
|
for (let t = 0; t < 180; t++) {
|
||||||
@@ -142,5 +131,5 @@ for (const mode of ['full', 'fast']) {
|
|||||||
})()`);
|
})()`);
|
||||||
check(`${mode}: streaming grinder leaves ground in daylight for 180 consecutive ticks`, result.mismatches === 0, result.first || 'no shadow');
|
check(`${mode}: streaming grinder leaves ground in daylight for 180 consecutive ticks`, result.mismatches === 0, result.first || 'no shadow');
|
||||||
}
|
}
|
||||||
check('the streaming grinder actually processes its input', run('stats.refined > 0'), run('stats.refined'));
|
check('the streaming grinder actually processes its input', await run('stats.refined > 0'), await run('stats.refined'));
|
||||||
done();
|
done();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Read float source channels, including transmittance, from the WGSL light pass.
|
||||||
|
function lightAt(a,x,y) { return a[(Math.floor(y/R.lres)-R.ly0)*R.lw+Math.floor(x/R.lres)-R.lx0]; }
|
||||||
|
async function testLight() {
|
||||||
|
if (!W) return;
|
||||||
|
renderDirty.fill(1);R.frame++;drawWorld();
|
||||||
|
const d=GPU.device;
|
||||||
|
if(!window.testLightPipe) window.testLightPipe=await d.createComputePipelineAsync({layout:'auto',compute:{module:d.createShaderModule({code:`
|
||||||
|
@group(0) @binding(0) var source:texture_2d<f32>;
|
||||||
|
@group(0) @binding(1) var<storage,read_write> out:array<vec4f>;
|
||||||
|
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id:vec3u){
|
||||||
|
let size=textureDimensions(source);let k=id.x;if(k>=size.x*size.y){return;}
|
||||||
|
out[k]=textureLoad(source,vec2i(i32(k%size.x),i32(k/size.x)),0);
|
||||||
|
}`}),entryPoint:'main'}});
|
||||||
|
const bytes=R.lw*R.lh*16, storage=d.createBuffer({size:bytes,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC});
|
||||||
|
const read=d.createBuffer({size:bytes,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
|
||||||
|
const bind=d.createBindGroup({layout:testLightPipe.getBindGroupLayout(0),entries:[{binding:0,resource:GPU.lightWork[0].createView()},{binding:1,resource:{buffer:storage}}]});
|
||||||
|
const enc=d.createCommandEncoder(),pass=enc.beginComputePass();pass.setPipeline(testLightPipe);pass.setBindGroup(0,bind);pass.dispatchWorkgroups(Math.ceil(R.lw*R.lh/64));pass.end();
|
||||||
|
enc.copyBufferToBuffer(storage,0,read,0,bytes);d.queue.submit([enc.finish()]);await read.mapAsync(GPUMapMode.READ);
|
||||||
|
const data=new Float32Array(read.getMappedRange());
|
||||||
|
[R.lr,R.lg,R.lb,R.latt]=[0,1,2,3].map(c=>Float32Array.from({length:R.lw*R.lh},(_,i)=>data[i*4+c]));
|
||||||
|
read.unmap();read.destroy();storage.destroy();
|
||||||
|
}
|
||||||
@@ -173,7 +173,7 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
|
|||||||
check('but not on the title screen', not ev(f'(() => {{ GAME.running = false; const r = {leave}; GAME.running = true; return r; }})()'))
|
check('but not on the title screen', not ev(f'(() => {{ GAME.running = false; const r = {leave}; GAME.running = true; return r; }})()'))
|
||||||
ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4
|
ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4
|
||||||
ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200)
|
ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200)
|
||||||
check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, SM.ok]'))
|
check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, GPU.ok]'))
|
||||||
# Maps use the real frame loop and keyboard, including settings persistence.
|
# Maps use the real frame loop and keyboard, including settings persistence.
|
||||||
pg.wait_for_function('MAP.image && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
|
pg.wait_for_function('MAP.image && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
|
||||||
check('minimap lives in bottom right with its controls', ev("!!document.querySelector('#hud .br #minimap canvas') && ['mini-plus', 'mini-minus', 'mini-map'].every(id => $(id).tagName === 'BUTTON')"))
|
check('minimap lives in bottom right with its controls', ev("!!document.querySelector('#hud .br #minimap canvas') && ['mini-plus', 'mini-minus', 'mini-map'].every(id => $(id).tagName === 'BUTTON')"))
|
||||||
@@ -1220,8 +1220,6 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
|
|||||||
# Fog has its own paused scene; existing play checks keep the whole world visible.
|
# Fog has its own paused scene; existing play checks keep the whole world visible.
|
||||||
fog_context = b.new_context(viewport={'width': 1280, 'height': 800})
|
fog_context = b.new_context(viewport={'width': 1280, 'height': 800})
|
||||||
fp = fog_context.new_page()
|
fp = fog_context.new_page()
|
||||||
if pg.evaluate('!!window.__allowSoftwareGL'):
|
|
||||||
fp.add_init_script('window.__allowSoftwareGL = true')
|
|
||||||
fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}'))
|
fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}'))
|
||||||
fp.goto(url)
|
fp.goto(url)
|
||||||
fp.evaluate('startNewWorld(4242, 512, 384)')
|
fp.evaluate('startNewWorld(4242, 512, 384)')
|
||||||
@@ -1328,12 +1326,11 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
|
|||||||
check('fog toggle shows the same rock', fp.evaluate(sample) != 0)
|
check('fog toggle shows the same rock', fp.evaluate(sample) != 0)
|
||||||
fp.evaluate("SET.fog = true; SET.look = 'smooth'; SM.t0 = performance.now() + 100000")
|
fp.evaluate("SET.fog = true; SET.look = 'smooth'; SM.t0 = performance.now() + 100000")
|
||||||
fp.wait_for_timeout(500)
|
fp.wait_for_timeout(500)
|
||||||
check('fog draws in the smooth look', fp.evaluate('R.smoothOn && (R.gpuOn ? GPU.ok : SM.ok)'))
|
check('fog draws in the smooth look', fp.evaluate('R.smoothOn && GPU.ok'))
|
||||||
fp.add_script_tag(path=str(root / 'test/gpu-readback.js'))
|
fp.add_script_tag(path=str(root / 'test/gpu-readback.js'))
|
||||||
check('unseen smooth grains have no terrain shape', fp.evaluate("""async () => {
|
check('unseen smooth grains have no terrain shape', fp.evaluate("""async () => {
|
||||||
const k=(235-R.vy0)*R.bw+300-R.vx0;
|
const k=(235-R.vy0)*R.bw+300-R.vx0;
|
||||||
const inputs=R.gpuOn ? Array.from((await testSmoothInputs()).slice(k*4,k*4+4)) :
|
const inputs=Array.from((await testSmoothInputs()).slice(k*4,k*4+4));
|
||||||
[R.px[k],R.backPx[k],R.treePx[k],R.fieldPx[k]];
|
|
||||||
return inputs[0]===0x04000000 && inputs[1]===0 && inputs[2]===0 && inputs[3]===255;
|
return inputs[0]===0x04000000 && inputs[1]===0 && inputs[2]===0 && inputs[3]===255;
|
||||||
}"""))
|
}"""))
|
||||||
fp.screenshot(path=str(out / 'fog-smooth.png'))
|
fp.screenshot(path=str(out / 'fog-smooth.png'))
|
||||||
|
|||||||
@@ -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', 't_gpu.py', 'firefox_smoke.py']:
|
for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']:
|
||||||
results.append(run_py(name))
|
results.append(run_py(name))
|
||||||
bad = 0
|
bad = 0
|
||||||
for name, code, out, secs in results:
|
for name, code, out, secs in results:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Capability detection, title warnings and the future startup gate."""
|
"""Capability detection, title notices and the required-WebGPU startup gate."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -8,23 +8,18 @@ ensure_playwright()
|
|||||||
from browser import capabilities, consume_gpu_option, launch
|
from browser import capabilities, consume_gpu_option, launch
|
||||||
|
|
||||||
consume_gpu_option()
|
consume_gpu_option()
|
||||||
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'required']:
|
for case in ['native', 'absent', 'null', 'reject', 'firefox', 'limits']:
|
||||||
with launch() as (_, page, url):
|
with launch() as (_, page, url):
|
||||||
errors = []
|
errors = []
|
||||||
page.on('pageerror', lambda e: errors.append(str(e)))
|
page.on('pageerror', lambda e: errors.append(str(e)))
|
||||||
if case != 'native':
|
if case != 'native':
|
||||||
gpu = 'undefined' if case == 'absent' else "{requestAdapter: async () => null}"
|
gpu = 'undefined' if case == 'absent' else "{requestAdapter: async () => null}"
|
||||||
if case == 'reject': gpu = "{requestAdapter: async () => {throw new Error('adapter denied')}}"
|
if case == 'reject': gpu = "{requestAdapter: async () => {throw new Error('adapter denied')}}"
|
||||||
|
if case == 'limits': gpu="{requestAdapter:async()=>({limits:{maxStorageTexturesPerShaderStage:3}})}"
|
||||||
page.add_init_script(f"Object.defineProperty(navigator, 'gpu', {{value: {gpu}}});")
|
page.add_init_script(f"Object.defineProperty(navigator, 'gpu', {{value: {gpu}}});")
|
||||||
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});")
|
page.add_init_script("Object.defineProperty(self, 'crossOriginIsolated', {value: false});")
|
||||||
if case == 'firefox':
|
if case == 'firefox':
|
||||||
page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});")
|
page.add_init_script("Object.defineProperty(navigator, 'userAgent', {value: 'Firefox/157.0'});")
|
||||||
if case == 'required':
|
|
||||||
def require(route):
|
|
||||||
response = route.fetch()
|
|
||||||
route.fulfill(response=response, body=response.text().replace(
|
|
||||||
'const REQUIRE_GPU_AND_THREADS = false;', 'const REQUIRE_GPU_AND_THREADS = true;'))
|
|
||||||
page.route('**/index.html', require)
|
|
||||||
page.goto(url)
|
page.goto(url)
|
||||||
caps = capabilities(page)
|
caps = capabilities(page)
|
||||||
page.wait_for_function("!$('title').hidden")
|
page.wait_for_function("!$('title').hidden")
|
||||||
@@ -36,17 +31,13 @@ for case in ['native', 'absent', 'null', 'reject', 'firefox', 'required']:
|
|||||||
notice = page.inner_text('#cap-notice')
|
notice = page.inner_text('#cap-notice')
|
||||||
assert 'python3 tools/serve.py' in notice and "opened from a file it can't" in notice
|
assert 'python3 tools/serve.py' in notice and "opened from a file it can't" in notice
|
||||||
assert ('Firefox Nightly' in notice and 'dom.webgpu.enabled' in notice) if case == 'firefox' else 'Chrome or Edge' in notice
|
assert ('Firefox Nightly' in notice and 'dom.webgpu.enabled' in notice) if case == 'firefox' else 'Chrome or Edge' in notice
|
||||||
if case == 'required':
|
if case != 'native':
|
||||||
assert not page.evaluate('canStartGame()')
|
assert not page.evaluate('canStartGame()')
|
||||||
page.evaluate('startNewWorld(424242, 512, 384)')
|
page.evaluate('startNewWorld(424242, 512, 384)')
|
||||||
assert not page.evaluate('GAME.running || GAME.loading')
|
assert not page.evaluate('GAME.running || GAME.loading')
|
||||||
assert not page.evaluate('startFromSave({})')
|
assert not page.evaluate('startFromSave({})')
|
||||||
else:
|
|
||||||
assert page.evaluate('canStartGame()')
|
|
||||||
if case == 'absent':
|
if case == 'absent':
|
||||||
Path('shots').mkdir(exist_ok=True)
|
Path('shots').mkdir(exist_ok=True)
|
||||||
page.screenshot(path='shots/t88-p1-notice.png')
|
page.screenshot(path='shots/t88-p1-notice.png')
|
||||||
page.evaluate("SET.look='pixel'; startNewWorld(424242, 512, 384)")
|
|
||||||
page.wait_for_function('GAME.running && !GAME.loading', timeout=60000)
|
|
||||||
assert not errors, errors
|
assert not errors, errors
|
||||||
print(f'PASS capabilities: {case}')
|
print(f'PASS capabilities: {case}')
|
||||||
|
|||||||
@@ -1,159 +1,125 @@
|
|||||||
"""WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss."""
|
"""Dirty uploads, default limits, warm pipelines, world replacement and device recovery."""
|
||||||
import pathlib, sys
|
import pathlib, sys
|
||||||
sys.path.insert(0,str(pathlib.Path(__file__).resolve().parent.parent/'tools'))
|
sys.path.insert(0,str(pathlib.Path(__file__).resolve().parent.parent/'tools'))
|
||||||
from use_venv import ensure_playwright; ensure_playwright()
|
from use_venv import ensure_playwright; ensure_playwright()
|
||||||
from browser import launch, consume_gpu_option
|
from browser import launch, consume_gpu_option
|
||||||
gpu_mode = consume_gpu_option()
|
consume_gpu_option()
|
||||||
root=pathlib.Path(__file__).resolve().parent.parent
|
root=pathlib.Path(__file__).resolve().parent.parent
|
||||||
with launch(viewport={'width': 960, 'height': 600}) as (browser, pg, url):
|
with launch(viewport={'width':960,'height':600}) as (_,pg,url):
|
||||||
errors=[]
|
errors=[]
|
||||||
pg.on('pageerror',lambda e:errors.append(str(e)))
|
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.on('console',lambda m:errors.append(m.text) if m.type=='error' else None)
|
||||||
pg.add_init_script('requestAnimationFrame=()=>0;')
|
pg.add_init_script('requestAnimationFrame=()=>0;')
|
||||||
pg.goto(url);pg.evaluate('CAPS_CHECK')
|
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')
|
assert pg.evaluate('GPU.ok'),pg.evaluate('GPU.error')
|
||||||
pg.evaluate('generateWorld(424242, 640, 384, null); enterGame(); GAME.paused=true;')
|
assert pg.evaluate('GPU.device.limits.maxStorageTexturesPerShaderStage===4')
|
||||||
pg.evaluate('() => {' + (root / 'tools/scenes/t88-renderer.js').read_text() + '}')
|
print('PASS all pipelines compile with default limits',pg.evaluate('GPU_LIMITS'))
|
||||||
result = pg.evaluate("""() => {
|
startup=pg.evaluate("""async()=>{
|
||||||
SET.look='pixel'; SET.zoom=6; SET.glow='off'; SET.lighting='off';
|
await startNewWorld(424242,640,384); GAME.paused=true;
|
||||||
resizeRender(); bot.x=CAM[0]-bot.w/2; bot.y=CAM[1]-bot.h/2; snapCamera(); R.frame=1000;
|
const samples=[];
|
||||||
|
for(let n=0;n<10;n++) {const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();samples.push(performance.now()-t);}
|
||||||
|
return {compile:GPU.initMs,warm:GPU.warmMs,first:samples[0],normal:samples.slice(1)};
|
||||||
|
}""")
|
||||||
|
assert startup['warm']>=0
|
||||||
|
print('PASS both looks submit behind loading screen; first visible frame ms',startup)
|
||||||
|
pg.add_script_tag(path=str(root/'tools/perf-scenes.js'))
|
||||||
|
pg.evaluate("perfScene('factory',1024,640); SET.look='pixel'; SET.zoom=2;resizeRender();snapCamera();drawWorld();")
|
||||||
|
result=pg.evaluate('''async () => {
|
||||||
const results=[];
|
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]) {
|
for(let n=0;n<240;n++) {
|
||||||
SET.lighting=lit?'full':'off'; SET.glow=lit?'low':'off';
|
simTick();
|
||||||
R.lens=lens; SET.thermalPalette=palette; SET.fog=fogOn;
|
if(n%20===0) {
|
||||||
R.rain=rain; SET.renderer='legacy';drawWorld(); const a=worldPixelContext().getImageData(0,0,960,600).data;
|
fogLook(bot.x,bot.y); R.camX=(n*7)%400; R.camY=(n*3)%280;
|
||||||
R.rain=rain; SET.renderer='webgpu';drawWorld(); const b=worldPixelContext().getImageData(0,0,960,600).data;
|
SET.look=n%40?'smooth':'pixel';R.frame++;drawWorld();
|
||||||
let max=0, sum=0, bad=0;
|
results.push(await gpuCheckUploads());
|
||||||
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 uses WebGPU')
|
|
||||||
pg.evaluate('() => {' + (root / 'tools/scenes/t88-renderer.js').read_text() + '}')
|
|
||||||
pg.add_script_tag(path=str(root / 'test/gpu-readback.js'))
|
|
||||||
# Compare all four smooth inputs, including valid zero tags and class-zero overrides.
|
|
||||||
smooth_inputs = pg.evaluate("""async () => {
|
|
||||||
// Every material also gets a plain, unowned cell (including part materials).
|
|
||||||
for(let m=0;m<NMAT;m++) {
|
|
||||||
const x=CAM[0]-16+(m%11)*3,y=CAM[1]-40+Math.floor(m/11)*2,i=y*W+x;
|
|
||||||
setCell(i,m,20,0);part[i]=mach[i]=occ[i]=bgm[i]=0;blockP[(y>>2)*BW+(x>>2)]=0;
|
|
||||||
}
|
}
|
||||||
|
drawWorld();await gpuCheckUploads();
|
||||||
|
drawWorld();return {checked:results,cleanCells:GPU.uploadCells};
|
||||||
|
}''')
|
||||||
|
assert min(result['checked'])>1000 and result['cleanCells']==0,result
|
||||||
|
print('PASS busy ticks, heat, fog, scrolling, both looks: GPU bytes equal full CPU data; clean frame uploads no world cells',result)
|
||||||
|
parity=pg.evaluate("""async()=>{
|
||||||
const results=[];
|
const results=[];
|
||||||
const now=performance.now.bind(performance); performance.now=()=>5000; SM.t0=0;
|
const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen;
|
||||||
try {
|
try {
|
||||||
for(const zoom of [12,6,2])for(const fogOn of [false,true]) {
|
for(const look of ['pixel','smooth'])for(const light of ['full','fast','off'])for(const lens of [false,true])for(const fogOn of [false,true]) {
|
||||||
SET.zoom=zoom;SET.fog=fogOn;SET.look='smooth';R.lens=false;SET.lighting='off';SET.glow='off';
|
SET.look=look;SET.lighting=light;R.lens=lens;SET.fog=fogOn;R.frame++;
|
||||||
resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();R.frame=999;
|
R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
|
||||||
SET.renderer='legacy';SM.scale=1;drawWorld();
|
const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||||
const expected=[R.px.slice(),R.backPx.slice(),R.treePx.slice(),R.fieldPx.slice()];
|
GPU.resident.fill(0);R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
|
||||||
SET.renderer='webgpu';drawWorld();
|
const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||||
const actual=await testSmoothInputs(); let alpha=0,max=0,bad=0,examples=[];
|
let max=0;for(let i=0;i<a.length;i++)max=Math.max(max,Math.abs(a[i]-b[i]));
|
||||||
for(let k=0;k<R.px.length;k++)for(let t=0;t<4;t++) {
|
results.push({look,light,lens,fogOn,max});
|
||||||
const a=expected[t][k],b=actual[k*4+t];if((a>>>24)!==(b>>>24))alpha++;
|
|
||||||
for(let c=0;c<3;c++){const diff=Math.abs((a>>>(c*8)&255)-(b>>>(c*8)&255));max=Math.max(max,diff);if(diff>1){bad++;if(examples.length<3)examples.push({k,t,a,b});}}
|
|
||||||
}
|
}
|
||||||
results.push({zoom,fogOn,alpha,max,bad,examples,share:GPU.overrideCount/R.px.length});
|
R.lens=false;SET.lighting='full';return results;
|
||||||
}
|
|
||||||
return results;
|
|
||||||
} finally {performance.now=now;}
|
} finally {performance.now=now;}
|
||||||
}""")
|
}""")
|
||||||
for r in smooth_inputs:
|
assert all(r['max']==0 for r in parity),parity
|
||||||
assert r['alpha'] == 0 and r['bad'] == 0, r
|
print('PASS dirty and forced full uploads render identically in',len(parity),'look/light/lens/fog configurations')
|
||||||
print('PASS all four smooth inputs match legacy: classes and flags exact, RGB within 1', smooth_inputs)
|
|
||||||
pg.evaluate('SET.look="pixel"; SET.renderer="webgpu"; PERF_MARKS=true; drawWorld();')
|
|
||||||
# This fixture stops requestAnimationFrame; poll asynchronous GPU events by timer.
|
|
||||||
pg.wait_for_function('!GPU.timingBusy', polling=10)
|
|
||||||
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('() => {' + (root / 'tools/scenes/t88-light.js').read_text() + '}')
|
|
||||||
# Draw GPU first with no CPU grid and forbid accidental CPU lighting calls.
|
|
||||||
pg.evaluate("""() => {
|
|
||||||
window.savedComputeLight=computeLight;
|
|
||||||
computeLight=()=>{throw Error('CPU light called in WebGPU');};
|
|
||||||
R.lr=R.lg=R.lb=R.latt=R.lightPx=null; R.lw=R.lh=0;
|
|
||||||
SET.renderer='webgpu';SET.look='pixel';SET.zoom=6;SET.lighting='full';SET.lightEvery=1;
|
|
||||||
resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();drawWorld();
|
|
||||||
computeLight=savedComputeLight;
|
|
||||||
}""")
|
|
||||||
for mode in ['full', 'fast']:
|
|
||||||
for case in ['lamp', 'opening', 'magma', 'fire']:
|
|
||||||
parity = pg.evaluate("""([mode,kind]) => {
|
|
||||||
lightScene(kind);SET.lighting=mode;SET.lightEvery=1;R.frame=1000;R.rain=0;
|
|
||||||
SET.renderer='legacy';drawWorld();const a=worldPixelContext().getImageData(0,0,960,600).data;
|
|
||||||
R.rain=0;SET.renderer='webgpu';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data;
|
|
||||||
let max=0,bad=0,sum=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;}
|
|
||||||
return {mode,kind,max,bad,mean:sum/(960*600*3)};
|
|
||||||
}""", [mode,case])
|
|
||||||
assert parity['bad'] == 0, parity
|
|
||||||
print('PASS light scene', parity)
|
|
||||||
cadence=pg.evaluate("""()=>{
|
cadence=pg.evaluate("""()=>{
|
||||||
SET.lightEvery=3;R.frame=1002;drawWorld();const old=GPU.lightFrame,origin=R.lx0;
|
SET.lightEvery=3;R.frame=1002;drawWorld();const old=GPU.lightFrame,origin=R.lx0;
|
||||||
bot.lamp=!bot.lamp;R.camX+=4;R.frame=1003;drawWorld();
|
R.camX+=4;R.frame=1003;drawWorld();const held=GPU.lightFrame===old && R.lx0===origin;
|
||||||
const held=GPU.lightFrame===old && R.lx0===origin;
|
|
||||||
R.frame=1005;drawWorld();const updated=GPU.lightFrame===1005 && R.lx0!==origin;
|
R.frame=1005;drawWorld();const updated=GPU.lightFrame===1005 && R.lx0!==origin;
|
||||||
SET.lightEvery=1;return {held,updated};
|
SET.lightEvery=1;return {held,updated};
|
||||||
}""")
|
}""")
|
||||||
assert all(cadence.values()),cadence
|
assert all(cadence.values()),cadence
|
||||||
print('PASS GPU-only lighting, both resolutions and lightEvery cadence')
|
print('PASS lighting cadence holds and then updates its camera origin')
|
||||||
switches = pg.evaluate("""() => {
|
# Force paths that can mutate sleeping chunks, away from the busy factory.
|
||||||
const original=computeLight,frames=[];
|
pg.evaluate('''() => {
|
||||||
computeLight=()=>{throw Error('CPU light called in WebGPU smooth');};
|
const i=500*W+800; setCell(i,STONE,20,F_NAT);digp[i]=120;renderDirtyIdx(i);
|
||||||
try {
|
setTreeCell(801,450,WOOD);fogRevealAll();
|
||||||
SET.lightEvery=100;
|
R.camX=650;R.camY=400;drawWorld();
|
||||||
for(const look of ['smooth','pixel','smooth']) {
|
}''')
|
||||||
SET.look=look;R.frame++;drawWorld();
|
assert pg.evaluate('gpuCheckUploads()')>1000
|
||||||
frames.push(R.gpuOn && GPU.lightFrame===R.frame && R.smoothOn===(look==='smooth'));
|
pg.evaluate('generateWorld(777,640,384,null);buildSky();resizeRender();snapCamera();drawWorld();')
|
||||||
}
|
assert pg.evaluate('GPU.world===mat && GPU.ok')
|
||||||
return frames;
|
assert pg.evaluate('gpuCheckUploads()')>1000
|
||||||
} finally {computeLight=original;SET.lightEvery=1;}
|
print('PASS world replacement refreshes resident chunks and static data')
|
||||||
|
assert pg.evaluate('R.ctx.getImageData(10,10,1,1).data[3]===0 && worldPixelContext().getImageData(10,10,1,1).data[3]===255')
|
||||||
|
print('PASS GPU world stays opaque under transparent overlays')
|
||||||
|
cracks=pg.evaluate("""async()=>{
|
||||||
|
SET.zoom=12;SET.look='pixel';SET.fog=false;SET.lighting='off';SET.glow='off';SET.grain=0;
|
||||||
|
resizeRender();R.camX=200;R.camY=160;
|
||||||
|
for(let y=170;y<192;y++)for(let x=215;x<260;x++)setCell(y*W+x,STONE,20,F_NAT);
|
||||||
|
drawWorld();const clean=worldPixelContext().getImageData(0,0,960,600).data;
|
||||||
|
for(let y=170;y<192;y++)for(let x=215;x<260;x++){digp[y*W+x]=240;renderDirtyXY(x,y);}
|
||||||
|
R.frame=1000;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
|
||||||
|
const damaged=worldPixelContext().getImageData(0,0,960,600).data;
|
||||||
|
let changed=0;for(let i=0;i<clean.length;i+=4)changed+=clean[i]!==damaged[i];
|
||||||
|
return changed;
|
||||||
}""")
|
}""")
|
||||||
assert all(switches), switches
|
assert cracks>1000,cracks
|
||||||
print('PASS switching either look refreshes new light textures without CPU lighting')
|
pg.screenshot(path=str(root/'shots/t88-drill-pixel.png'))
|
||||||
assert pg.evaluate('!GPU.queries || GPU.lightMs>=0')
|
pg.evaluate("SET.look='smooth';drawWorld()")
|
||||||
pg.evaluate('GPU.device.destroy()')
|
pg.screenshot(path=str(root/'shots/t88-drill-smooth.png'))
|
||||||
pg.wait_for_function('!GPU.ok', polling=10)
|
print('PASS stationary drill damage changes GPU colour in both looks',cracks)
|
||||||
assert pg.evaluate('SET.renderer==="legacy"')
|
timing=pg.evaluate('''async () => {
|
||||||
|
const originalC=GPU.device.createComputePipeline,originalR=GPU.device.createRenderPipeline;
|
||||||
|
GPU.device.createComputePipeline=GPU.device.createRenderPipeline=()=>{throw Error('synchronous pipeline compilation');};
|
||||||
|
const samples=[];
|
||||||
|
try {
|
||||||
|
for(const [look,zoom] of [['pixel',12],['smooth',12],['smooth',2],['pixel',2],['smooth',6]]) {
|
||||||
|
SET.look=look;SET.zoom=zoom;resizeRender();snapCamera();const start=performance.now();drawWorld();
|
||||||
|
await GPU.device.queue.onSubmittedWorkDone();samples.push({look,zoom,first:performance.now()-start});
|
||||||
|
const times=[];for(let n=0;n<8;n++){const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();times.push(performance.now()-t);}
|
||||||
|
samples.at(-1).normal=Math.max(...times);
|
||||||
|
}
|
||||||
|
} finally {GPU.device.createComputePipeline=originalC;GPU.device.createRenderPipeline=originalR;}
|
||||||
|
return {init:GPU.initMs,samples};
|
||||||
|
}''')
|
||||||
|
print('PASS look and zoom switches create no pipelines; timings ms',timing)
|
||||||
|
# Successful recovery must recreate resources and preserve simulation arrays.
|
||||||
|
pg.evaluate('window.savedMat=mat; GPU.device.destroy();')
|
||||||
|
pg.wait_for_function('GPU.ok && !GPU.recovering',polling=20,timeout=60000)
|
||||||
|
assert pg.evaluate('mat===savedMat')
|
||||||
pg.evaluate('drawWorld()')
|
pg.evaluate('drawWorld()')
|
||||||
print('PASS device loss restores the legacy renderer')
|
assert pg.evaluate('gpuCheckUploads()')>1000
|
||||||
|
print('PASS device loss recreates the device once without changing the world')
|
||||||
|
pg.evaluate("Object.defineProperty(navigator,'gpu',{value:{requestAdapter:async()=>null}});GPU.device.destroy();")
|
||||||
|
pg.wait_for_function('GPU.stopped',polling=20,timeout=60000)
|
||||||
|
assert pg.evaluate('GAME.paused') and 'Reload' in pg.inner_text('#m-dialog')
|
||||||
|
assert pg.evaluate("DB.get(GAME.worldId+':auto').then(s=>!!s)")
|
||||||
|
print('PASS failed recovery stops frames, autosaves and offers Reload')
|
||||||
assert not errors,errors
|
assert not errors,errors
|
||||||
print('PASS no page or WebGPU validation errors')
|
print('PASS no page or WebGPU validation errors')
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Frozen legacy references, captured before retirement; compare the WebGPU world only."""
|
||||||
|
import base64, pathlib, sys, subprocess
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
|
||||||
|
from use_venv import ensure_playwright; ensure_playwright()
|
||||||
|
from browser import launch, consume_gpu_option
|
||||||
|
consume_gpu_option()
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
GOLD = ROOT / 'test/fixtures/golden'
|
||||||
|
with launch(viewport={'width': 640, 'height': 480}) as (_, pg, url):
|
||||||
|
errors=[]
|
||||||
|
pg.on('pageerror', lambda e: errors.append(str(e)))
|
||||||
|
pg.on('console', lambda m: errors.append(m.text) if m.type=='error' else None)
|
||||||
|
pg.add_init_script('requestAnimationFrame=()=>0;')
|
||||||
|
pg.goto(url); pg.evaluate('CAPS_CHECK')
|
||||||
|
assert pg.evaluate('GPU.ok'), pg.evaluate('GPU.error')
|
||||||
|
for scene in ['renderer','joins','sky','lamp','opening','magma','fire']:
|
||||||
|
for look in ['pixel','smooth']:
|
||||||
|
for zoom in [12,2]:
|
||||||
|
pg.evaluate('(h)=>{generateWorld(424242,640,h,null);enterGame();GAME.paused=true;}',512 if scene=='joins' else 384)
|
||||||
|
name = 'joins' if scene=='joins' else 't88-renderer' if scene in ['renderer','sky'] else 't88-light'
|
||||||
|
pg.evaluate('() => {'+(ROOT / f'tools/scenes/{name}.js').read_text()+'}')
|
||||||
|
if scene not in ['renderer','joins','sky']:
|
||||||
|
pg.evaluate('s=>lightScene(s)',scene)
|
||||||
|
if scene=='joins':
|
||||||
|
pg.evaluate('SET.fog=false; CAM=[280,98];')
|
||||||
|
if scene=='sky':
|
||||||
|
pg.evaluate('CAM=[320,55]; SET.fog=false;')
|
||||||
|
pg.evaluate("""([look,zoom])=>{
|
||||||
|
SET.look=look;SET.zoom=zoom;SET.lighting='full';SET.lightEvery=1;SET.glow='low';
|
||||||
|
SM.t0=0; SM.surf=-1;
|
||||||
|
performance.now=()=>5000;resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;
|
||||||
|
snapCamera();R.frame=1000;drawWorld();
|
||||||
|
}""",[look,zoom])
|
||||||
|
raw=pg.evaluate("""() => {
|
||||||
|
const c=document.createElement('canvas');c.width=384;c.height=288;
|
||||||
|
c.getContext('2d').drawImage(worldPixelContext().canvas,128,96,384,288,0,0,384,288);
|
||||||
|
return c.toDataURL().split(',')[1];
|
||||||
|
}""")
|
||||||
|
path=GOLD/f'{scene}-{look}-{zoom}.png'
|
||||||
|
actual=base64.b64decode(raw)
|
||||||
|
rgb=lambda data: subprocess.run(['magick','png:-','-alpha','off','-depth','8','rgb:-'],input=data,capture_output=True,check=True).stdout
|
||||||
|
a,b=rgb(actual),rgb(path.read_bytes())
|
||||||
|
ds=[abs(x-y) for x,y in zip(a,b)]
|
||||||
|
mean=sum(ds)/len(ds);bad=sum(max(ds[i:i+3])>8 for i in range(0,len(ds),3))
|
||||||
|
assert mean<0.5 and bad<384*288*0.003, (path.name,mean,bad)
|
||||||
|
print('PASS golden',path.name,round(mean,4),bad)
|
||||||
|
(ROOT/'shots'/('golden-'+path.name)).write_bytes(actual)
|
||||||
|
assert not errors,errors
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""The former CPU light behavioural checks, now read from the actual GPU light grid."""
|
||||||
|
import pathlib,sys
|
||||||
|
sys.path.insert(0,str(pathlib.Path(__file__).resolve().parent.parent/'tools'))
|
||||||
|
from use_venv import ensure_playwright;ensure_playwright()
|
||||||
|
from browser import launch,consume_gpu_option
|
||||||
|
consume_gpu_option()
|
||||||
|
root=pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
with launch(viewport={'width':636,'height':156}) as (_,pg,url):
|
||||||
|
errors=[]
|
||||||
|
pg.on('pageerror',lambda e:errors.append(str(e)))
|
||||||
|
pg.on('console',lambda m:errors.append(m.text) if m.type=='error' else None)
|
||||||
|
pg.add_init_script('requestAnimationFrame=()=>0;')
|
||||||
|
pg.goto(url);pg.evaluate('CAPS_CHECK')
|
||||||
|
pg.add_script_tag(path=str(root/'test/light-readback.js'))
|
||||||
|
source=(root/'test/light-cases.js').read_text()
|
||||||
|
result=pg.evaluate("""async source=>{
|
||||||
|
const results=[];
|
||||||
|
function check(name,ok,detail){results.push({name,ok:!!ok,detail});}
|
||||||
|
function done(){}
|
||||||
|
async function run(code){
|
||||||
|
code=code.replaceAll('computeLight();','await testLight();');
|
||||||
|
let value;
|
||||||
|
if(code.includes('await ')) {
|
||||||
|
if(code.trim().startsWith('(() =>')) code=code.replace('(() =>','(async () =>');
|
||||||
|
else code='(async()=>{'+code+'})()';
|
||||||
|
}
|
||||||
|
value=await (0,eval)(code);
|
||||||
|
if(!code.includes('testLight()')) await testLight();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async function flatWorld(run,w,h,g){
|
||||||
|
await run(`alloc(${w},${h});worldSeed=7;for(let x=0;x<W;x++){surfY[x]=${g};dirtY[x]=${g}+10;graniteY[x]=basaltY[x]=H;}
|
||||||
|
computeAmbient();fillNoise();for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x;mat[i]=y>=${g}?STONE:EMPTY;flags[i]=y>=${g}?F_NAT:0;temp[i]=AMB[y];}
|
||||||
|
bot.x=(W>>1)-8;bot.y=${g}-30;bag=new Int32Array(NMAT);bagTotal=0;player.tankTier=3;updateRoof();resetWeather();weather.next=1e15;buildSky();`);
|
||||||
|
}
|
||||||
|
SET.zoom=2;SET.look='pixel';SET.fog=false;SET.glow='off';SET.daylight=1;SET.ambient=0.12;SET.lightEvery=1;
|
||||||
|
initRender(canvas);resizeRender();R.camX=0;R.camY=100;
|
||||||
|
return await eval('(async()=>{'+source+';return results;})()');
|
||||||
|
}""",source)
|
||||||
|
for r in result:
|
||||||
|
print(('PASS ' if r['ok'] else 'FAIL ')+r['name']+' '+str(r.get('detail','')))
|
||||||
|
assert all(r['ok'] for r in result)
|
||||||
|
assert not errors,errors
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Render invalidation must not depend on whether a grain moved or the chunk stayed awake.
|
||||||
|
const {load,suite,flatWorld}=require('./harness');
|
||||||
|
const {run}=load(),{check,done}=suite();
|
||||||
|
flatWorld(run,128,128,100);
|
||||||
|
function marked(code,x,y){run('renderDirty.fill(0);'+code);return run(`!!renderDirty[(${y}>>5)*CW+(${x}>>5)]`);}
|
||||||
|
run('setCell(65*W+65,STONE,20,F_NAT);bot.x=63;bot.y=60;');
|
||||||
|
check('stationary drill damage invalidates the rock',marked('toolDig(65,65,0);',65,65) && run('digp[65*W+65]>0 && mat[65*W+65]===STONE'));
|
||||||
|
check('waking a cell invalidates its render chunk',marked('wakeXY(31,31);',31,31));
|
||||||
|
check('swapping cells invalidates both render chunks',marked('swapCells(10*W+10,70*W+70);',70,70)&&run('!!renderDirty[0]'));
|
||||||
|
check('exploration invalidates its render chunk',marked('fogMark(70,70,255);',70,70));
|
||||||
|
check('revealing the whole map invalidates every chunk',marked('fogRevealAll();',70,70)&&run('renderDirty.every(v=>v===1)'));
|
||||||
|
check('heat invalidates the chunk and conduction across its edges',marked('heatChunk(5,1);',64,40)&&run('!!renderDirty[1] && !!renderDirty[4] && !!renderDirty[9]'));
|
||||||
|
check('tree growth invalidates the background',marked('setTreeCell(70,70,WOOD);',70,70));
|
||||||
|
run('setCell(60*W+60,FIRE,900,0);life[60*W+60]=10;for(const j of [60*W+59,60*W+61,59*W+60,61*W+60])setCell(j,STONE,20,F_NAT);');
|
||||||
|
check('blocked fire aging invalidates without movement',marked('gas(60*W+60,60,60,FIRE);',60,60) && run('life[60*W+60]===9'));
|
||||||
|
run('setCell(32*W+64,SAND,20,0);part[32*W+64]=P_GOPEN;setCell(31*W+64,EMPTY,20,0);');
|
||||||
|
check('closing a gate invalidates the displaced grain across a chunk edge',marked('toggleGateAt(64,32);',64,31) && run('mat[31*W+64]===SAND'));
|
||||||
|
done();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The smooth look: the WebGPU pass compiles, draws, falls back cleanly, and the thermal lens still works."""
|
"""The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works."""
|
||||||
import sys, pathlib
|
import sys, pathlib
|
||||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
|
||||||
from use_venv import ensure_playwright; ensure_playwright()
|
from use_venv import ensure_playwright; ensure_playwright()
|
||||||
@@ -19,7 +19,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
|
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
|
||||||
pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500)
|
pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500)
|
||||||
ev = pg.evaluate
|
ev = pg.evaluate
|
||||||
check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && GPU.ok && R.gpuOn && 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' && GPU.ok && R.gpuOn && R.smoothOn"), ev('[SET.look, GPU.ok, GPU.error, 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 = 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)]; })()""")
|
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)
|
||||||
@@ -27,15 +27,11 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
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)
|
||||||
check('the thermal lens draws in pixels', ev('R.lens && !R.smoothOn'))
|
check('the thermal lens draws in pixels', ev('R.lens && !R.smoothOn'))
|
||||||
pg.keyboard.press('h'); pg.wait_for_timeout(1500)
|
pg.keyboard.press('h'); pg.wait_for_timeout(1500)
|
||||||
check('and the smooth look comes back after it', ev('!R.lens && R.smoothOn'), ev('[R.lens, R.smoothOn, SET.look, SM.ok, SM.why, SM.ms, SM.scale]'))
|
check('and the smooth look comes back after it', ev('!R.lens && R.smoothOn'), ev('[R.lens, R.smoothOn, SET.look, GPU.ok, GPU.error]'))
|
||||||
ev("SET.look = 'pixel'"); pg.wait_for_timeout(1500)
|
ev("SET.look = 'pixel'"); pg.wait_for_timeout(1500)
|
||||||
check('the pixel look can be chosen', ev('!R.smoothOn'))
|
check('the pixel look can be chosen', ev('!R.smoothOn'))
|
||||||
pg.screenshot(path=str(out / 'pixel.png'))
|
pg.screenshot(path=str(out / 'pixel.png'))
|
||||||
ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500)
|
ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500)
|
||||||
ev("SET.renderer = 'legacy'; SM.scale = 0.5"); pg.wait_for_timeout(1500)
|
|
||||||
st = ev('[R.smoothOn, SM.scale, SM.canvas.width, R.canvas.width, SET.look, SM.ok, SM.why]'); check('half-resolution smooth still draws', (st[0] and st[2] == round(st[3] * st[1])) or st[1] > 0.5, st)
|
|
||||||
ev("SET.renderer = 'webgpu'"); pg.wait_for_timeout(300)
|
|
||||||
check('smooth returns to WebGPU after legacy comparison', ev('R.gpuOn && R.smoothOn'))
|
|
||||||
# Uniform colours isolate the solid/loose seam from material noise and lighting.
|
# Uniform colours isolate the solid/loose seam from material noise and lighting.
|
||||||
ground = ev('''() => {
|
ground = ev('''() => {
|
||||||
GAME.paused = true; requestAnimationFrame = () => 0;
|
GAME.paused = true; requestAnimationFrame = () => 0;
|
||||||
@@ -50,16 +46,16 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
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 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); renderDirty.fill(1); GPU.world=null; drawWorld();
|
||||||
const edge = at(140, 140), inside = at(140, 142), loose = at(140, 130);
|
const edge = at(140, 140), inside = at(140, 142), loose = at(140, 130);
|
||||||
fill(STONE); drawWorld();
|
fill(STONE); renderDirty.fill(1); GPU.world=null; drawWorld();
|
||||||
out[lens ? 'thermal' : 'pixel'] = [edge, inside, loose, at(140, 140)];
|
out[lens ? 'thermal' : 'pixel'] = [edge, inside, loose, at(140, 140)];
|
||||||
}
|
}
|
||||||
R.lens = false;
|
R.lens = false;
|
||||||
const i = 140 * W + 140;
|
const i = 140 * W + 140;
|
||||||
flags[i] = 0; fill(RSTONE); drawWorld(); const built = at(140, 140);
|
flags[i] = 0; fill(RSTONE); renderDirty.fill(1); GPU.world=null; drawWorld(); const built = at(140, 140);
|
||||||
fill(STONE); drawWorld(); out.built = [built, at(140, 140)]; flags[i] = F_NAT;
|
fill(STONE); renderDirty.fill(1); GPU.world=null; drawWorld(); out.built = [built, at(140, 140)]; flags[i] = F_NAT;
|
||||||
fill(RSTONE); SET.look = 'smooth'; SM.scale = 1; drawWorld();
|
fill(RSTONE); SET.look = 'smooth'; renderDirty.fill(1); GPU.world=null; drawWorld();
|
||||||
const sample = (x, y) => {
|
const sample = (x, y) => {
|
||||||
const p = worldToScreenPx(x, y);
|
const p = worldToScreenPx(x, y);
|
||||||
const c = worldPixelContext().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;
|
||||||
@@ -125,8 +121,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
check('off hides both label and vein outline', not cursor['off'] and cursor['offStrokes'] == 0)
|
check('off hides both label and vein outline', not cursor['off'] and cursor['offStrokes'] == 0)
|
||||||
for look in ['pixel', 'smooth']:
|
for look in ['pixel', 'smooth']:
|
||||||
ev("""look => {
|
ev("""look => {
|
||||||
SET.look=look; SM.scale=1; SET.cursorLabel='ores'; GAME.aim={x:124,y:122}; cursorLabelKey=null;
|
SET.look=look; SET.cursorLabel='ores'; GAME.aim={x:124,y:122}; cursorLabelKey=null;
|
||||||
$('cards').replaceChildren(); renderInfo(); drawWorld(); drawHoveredVein(); drawBot();
|
$('cards').replaceChildren(); renderInfo(); renderDirty.fill(1); GPU.world=null; drawWorld(); drawHoveredVein(); drawBot();
|
||||||
const p=worldToScreenPx(124,122); drawCursorLabel(p.x,p.y,1);
|
const p=worldToScreenPx(124,122); drawCursorLabel(p.x,p.y,1);
|
||||||
}""", look)
|
}""", look)
|
||||||
pg.screenshot(path=str(root / 'shots' / ('t77-vein.png' if look == 'pixel' else 't77-vein-smooth.png')))
|
pg.screenshot(path=str(root / 'shots' / ('t77-vein.png' if look == 'pixel' else 't77-vein-smooth.png')))
|
||||||
@@ -145,8 +141,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
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; GPU.world=null;
|
noiseB[cell]=noiseB[control]=128; GPU.world=null;
|
||||||
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; };
|
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';renderDirty.fill(1); GPU.world=null; drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff];
|
||||||
SET.look='smooth';drawWorld();const grainClass=at(cell)>>>24;
|
SET.look='smooth';renderDirty.fill(1); GPU.world=null; drawWorld();const grainClass=at(cell)>>>24;
|
||||||
return {colors,grainClass};
|
return {colors,grainClass};
|
||||||
}''')
|
}''')
|
||||||
check('sidewall stays behind the grain colour in pixels and smooth',
|
check('sidewall stays behind the grain colour in pixels and smooth',
|
||||||
@@ -163,7 +159,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
try {
|
try {
|
||||||
const out={};
|
const out={};
|
||||||
for(const look of ["pixel","smooth"]) {
|
for(const look of ["pixel","smooth"]) {
|
||||||
SET.look=look;SM.scale=1;drawWorld();
|
SET.look=look;renderDirty.fill(1); GPU.world=null; 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(worldPixelContext().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)) {
|
||||||
@@ -215,9 +211,9 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
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(worldPixelContext().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);
|
renderDirty.fill(1); GPU.world=null; 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();
|
renderDirty.fill(1); GPU.world=null; drawWorld();
|
||||||
if(sample(ux,uy)!==bg || sample(2,2)===empty)bad.push(['ghost elbow silhouette',look,j]);
|
if(sample(ux,uy)!==bg || sample(2,2)===empty)bad.push(['ghost elbow silhouette',look,j]);
|
||||||
ghostBlocks.clear();
|
ghostBlocks.clear();
|
||||||
const ctx=R.ctx,canvas=document.createElement('canvas');canvas.width=canvas.height=192;
|
const ctx=R.ctx,canvas=document.createElement('canvas');canvas.width=canvas.height=192;
|
||||||
@@ -234,16 +230,16 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
}''')
|
}''')
|
||||||
check('both looks: ghosts and planned previews leave the unused elbow corner empty', not previews, previews[:2])
|
check('both looks: ghosts and planned previews leave the unused elbow corner empty', not previews, previews[:2])
|
||||||
tree_tags = ev('''() => {
|
tree_tags = ev('''() => {
|
||||||
SET.look='smooth';SM.scale=1;
|
SET.look='smooth';
|
||||||
const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen;
|
const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen;
|
||||||
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(worldPixelContext().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=[];
|
renderDirty.fill(1); GPU.world=null; 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;
|
||||||
drawWorld();if(sample()!==plain)bad.push(m);
|
renderDirty.fill(1); GPU.world=null; drawWorld();if(sample()!==plain)bad.push(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]=0;
|
for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=0;
|
||||||
return bad;
|
return bad;
|
||||||
@@ -257,8 +253,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
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(worldPixelContext().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;renderDirty.fill(1); GPU.world=null; drawWorld();const joined=sample();
|
||||||
removeBlock(j.nx,j.ny);drawWorld();const free=sample();
|
removeBlock(j.nx,j.ny);renderDirty.fill(1); GPU.world=null; drawWorld();const free=sample();
|
||||||
placeBlock(BP_BELT,j.straight,j.nx,j.ny,true);
|
placeBlock(BP_BELT,j.straight,j.nx,j.ny,true);
|
||||||
out[look]=[joined,free];
|
out[look]=[joined,free];
|
||||||
}
|
}
|
||||||
@@ -289,12 +285,5 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
|||||||
check('no errors', not errs, errs[:3])
|
check('no errors', not errs, errs[:3])
|
||||||
b.close()
|
b.close()
|
||||||
|
|
||||||
# a browser without hardware drawing gets the pixel look and a note
|
|
||||||
with launch(gpu='soft', viewport={'width': 800, 'height': 500}) as (_, pg2, soft_url):
|
|
||||||
pg2.add_init_script('window.__allowSoftwareGL = false; localStorage.clear();')
|
|
||||||
pg2.goto(soft_url); pg2.evaluate("SET.fog = false; SET.renderer='legacy'"); pg2.wait_for_timeout(300)
|
|
||||||
pg2.click('#btn-new'); pg2.fill('#nw-seed', '5'); pg2.click('#btn-generate')
|
|
||||||
pg2.wait_for_function('GAME.running === true', timeout=20000); pg2.wait_for_timeout(1200)
|
|
||||||
check('software rendering falls back to pixels with a note', pg2.evaluate("SET.look === 'pixel' && !R.smoothOn && SM.why === 'software'") and pg2.locator('.toast', has_text='graphics card').count() >= 1, pg2.evaluate('[SET.look, SM.why]'))
|
|
||||||
print('FAILS:', fails)
|
print('FAILS:', fails)
|
||||||
sys.exit(1 if fails else 0)
|
sys.exit(1 if fails else 0)
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ def launch(*, gpu=None, browser_name='chromium', viewport=None, page_path=None,
|
|||||||
try:
|
try:
|
||||||
context = browser.new_context(viewport=viewport or {'width': 1280, 'height': 760})
|
context = browser.new_context(viewport=viewport or {'width': 1280, 'height': 760})
|
||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
if gpu == 'soft':
|
|
||||||
page.add_init_script('window.__allowSoftwareGL = true;')
|
|
||||||
yield browser, page, base + '/' + quote(path.name)
|
yield browser, page, base + '/' + quote(path.name)
|
||||||
finally:
|
finally:
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|||||||
@@ -41,13 +41,7 @@ async function perfRender(lighting, overlays) {
|
|||||||
draw += perfLast.drawWorld / n;
|
draw += perfLast.drawWorld / n;
|
||||||
for (const [key, value] of Object.entries(perfMarks)) marks[key] = (marks[key] || 0) + value / n;
|
for (const [key, value] of Object.entries(perfMarks)) marks[key] = (marks[key] || 0) + value / n;
|
||||||
}
|
}
|
||||||
let light = 0;
|
const light = 0;
|
||||||
if (!R.gpuOn) {
|
|
||||||
for (let k = 0; k < 5; k++) computeLight();
|
|
||||||
const l0 = performance.now();
|
|
||||||
for (let k = 0; k < n; k++) computeLight();
|
|
||||||
light = (performance.now() - l0) / n;
|
|
||||||
}
|
|
||||||
// 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);
|
||||||
@@ -57,7 +51,7 @@ async function perfRender(lighting, overlays) {
|
|||||||
const frame_ms = (performance.now() - frameStart) / n;
|
const frame_ms = (performance.now() - frameStart) / n;
|
||||||
// One completed timestamp sample for this configuration, outside the CPU measurements.
|
// One completed timestamp sample for this configuration, outside the CPU measurements.
|
||||||
let gpu_ms = null;
|
let gpu_ms = null;
|
||||||
if (R.gpuOn && GPU.queries) {
|
if (GPU.queries) {
|
||||||
await GPU.timingPromise;
|
await GPU.timingPromise;
|
||||||
PERF_MARKS = true; drawWorld();
|
PERF_MARKS = true; drawWorld();
|
||||||
await GPU.timingPromise;
|
await GPU.timingPromise;
|
||||||
@@ -66,7 +60,7 @@ async function perfRender(lighting, overlays) {
|
|||||||
return { frame_ms, drawWorld_ms: draw, computeLight_ms: light, marks_ms: marks,
|
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: R.gpuOn ? 1 : SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms };
|
scale: 1, actual_look: SET.look, renderer: 'webgpu', override_cells: GPU.overrideCount || 0, gpu_ms };
|
||||||
} finally { PERF_MARKS = false; }
|
} finally { PERF_MARKS = false; }
|
||||||
}
|
}
|
||||||
function perfRun(seconds) {
|
function perfRun(seconds) {
|
||||||
@@ -84,7 +78,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: R.gpuOn ? 1 : SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms: R.gpuOn ? GPU.gpuMs : null });
|
resolve({ samples, end: perfState(), scale: 1, actual_look: SET.look, renderer: 'webgpu', override_cells: GPU.overrideCount || 0, gpu_ms: GPU.gpuMs });
|
||||||
}, seconds * 1000);
|
}, seconds * 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,6 @@ function perfView(look, zoom) {
|
|||||||
SET.look = look; SET.zoom = zoom; SET.lighting = 'full';
|
SET.look = look; SET.zoom = zoom; SET.lighting = 'full';
|
||||||
resizeRender(); snapCamera();
|
resizeRender(); snapCamera();
|
||||||
for (let k = 0; k < 5; k++) { R.frame++; drawWorld(); }
|
for (let k = 0; k < 5; k++) { R.frame++; drawWorld(); }
|
||||||
if (look === 'smooth' && (!R.smoothOn || (!R.gpuOn && !SM.ok))) throw Error('Smooth look unavailable: ' + SM.why);
|
if (look === 'smooth' && !R.smoothOn) throw Error('Smooth look unavailable');
|
||||||
return { grains: R.bw * R.bh, buffer: [R.bw, R.bh], scale: R.gpuOn ? 1 : SM.scale };
|
return { grains: R.bw * R.bh, buffer: [R.bw, R.bh], scale: 1 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,10 +162,8 @@ def main():
|
|||||||
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('''() => {
|
||||||
if (!smoothAvailable()) throw Error('Real GPU unavailable: ' + SM.why);
|
return { renderer: 'WebGPU ' + CAPS.adapterInfo.vendor + ' ' + CAPS.adapterInfo.architecture,
|
||||||
const gl = SM.gl, dbg = gl.getExtension('WEBGL_debug_renderer_info');
|
vendor: CAPS.adapterInfo.vendor,
|
||||||
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),
|
|
||||||
cores: navigator.hardwareConcurrency, crossOriginIsolated, webgpu: !!navigator.gpu,
|
cores: navigator.hardwareConcurrency, crossOriginIsolated, webgpu: !!navigator.gpu,
|
||||||
adapter: CAPS.adapterInfo, timestamp_query: !!GPU.queries,
|
adapter: CAPS.adapterInfo, timestamp_query: !!GPU.queries,
|
||||||
user_agent: navigator.userAgent, dpr: devicePixelRatio };
|
user_agent: navigator.userAgent, dpr: devicePixelRatio };
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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)
|
||||||
@@ -52,7 +51,6 @@ 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')
|
||||||
@@ -78,7 +76,6 @@ def main():
|
|||||||
pg.goto(url)
|
pg.goto(url)
|
||||||
pg.evaluate("SET.fog = false")
|
pg.evaluate("SET.fog = false")
|
||||||
pg.evaluate('CAPS_CHECK')
|
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
|
||||||
@@ -93,7 +90,7 @@ def main():
|
|||||||
if code:
|
if code:
|
||||||
pg.evaluate(f'() => {{ {code}\n}}')
|
pg.evaluate(f'() => {{ {code}\n}}')
|
||||||
# Setup scenes can change surfY after the renderer cached the world's horizon.
|
# Setup scenes can change surfY after the renderer cached the world's horizon.
|
||||||
pg.evaluate('SM.surf = -1')
|
pg.evaluate('SM.surf = -1; renderDirty.fill(1); GPU.world=null;')
|
||||||
if a.ticks:
|
if a.ticks:
|
||||||
pg.evaluate(f'for (let k = 0; k < {a.ticks}; k++) simTick();')
|
pg.evaluate(f'for (let k = 0; k < {a.ticks}; k++) simTick();')
|
||||||
pg.evaluate(f"""GAME.paused = true; $('hud').hidden = {'false' if a.hud else 'true'}; GAME.hidePreviews = {'false' if a.previews else 'true'};
|
pg.evaluate(f"""GAME.paused = true; $('hud').hidden = {'false' if a.hud else 'true'}; GAME.hidePreviews = {'false' if a.previews else 'true'};
|
||||||
@@ -105,17 +102,15 @@ 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.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(','))
|
||||||
kw['clip'] = {'x': x, 'y': y, 'width': w, 'height': h}
|
kw['clip'] = {'x': x, 'y': y, 'width': w, 'height': h}
|
||||||
pathlib.Path(a.out).parent.mkdir(parents=True, exist_ok=True)
|
pathlib.Path(a.out).parent.mkdir(parents=True, exist_ok=True)
|
||||||
pg.screenshot(path=a.out, **kw)
|
pg.screenshot(path=a.out, **kw)
|
||||||
note = pg.evaluate("SET.look === 'smooth' ? (R.smoothOn ? 'smooth look' : 'smooth look FAILED: ' + SM.why) : 'pixel look'")
|
note = pg.evaluate("SET.look === 'smooth' ? (R.smoothOn ? 'smooth look' : 'smooth look FAILED') : 'pixel look'")
|
||||||
b.close()
|
b.close()
|
||||||
print(f"saved {a.out} ({note}, {a.renderer})")
|
print(f'saved {a.out} ({note}, WebGPU)')
|
||||||
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)
|
||||||
|
|||||||