Skip to content

TrailView ground plan GP9: ownership in the viewer: floors, the strip, pieces, draw order, range, crack count #3103

Closed

Description

@timohueser

Rewritten to the one road model (#3096); the previous body is in this issue's edit history.

Status: approved
Part of #3096. One pull request into ground-plan.
Depends on: #3101 (GP7), #3102 (GP8, after its entry gate). The question-4 views with heights.legs = wall need the leg split (GP3 follow-up, #3132).

TL;DR

Goal: With ground=plan, the viewer draws GP7's pieces near the eye before the terrain, holds the terrain under them by its floors, and closes the mesh against the terrain at the strip, so that no crack shows anywhere and no terrain pixel shows inside the owned region within the floors' range, at level 18 and at level 17. Past R_eff the terrain shows GP8's far look of the same records. The snap moves terrain vertices onto the model's core outline only past the floors. The far look stops where pieces draw. A GPU count proves the ownership over the walk and drive flights, and the whole road share of a frame, pieces and the lean pipeline included, is no larger than develop's, low tier first.
Problem: GP7 builds pieces and floors, and GP8 paints and shapes every page from the records, but nothing draws the pieces. The terrain has no floor, no draw takes a piece, the range is not fixed in the LOD metric, nothing says what draws while a cell is not built yet, and no count finds a crack at eye height. Without GP9, GP8's far look paints at every distance, its snap moves every terrain vertex near a road, including the vertices that the floors and the strip must keep on the grid, and the road terms of the look cost every detail fragment near the eye where the pieces draw the roads anyway.
Decision: The ownership stays: the floor slot per node, plan/pieces.ts with its pools, R_eff, the strip through the terrain's own grid_vertex, @invariant clip positions, debug=owned. Three parts change: (1) past R_eff the terrain shows GP8's far look (B); (2) the snap factor is smoothstep(floor_range, plan_range, d) (1 at R = 0), the same in vertex_main and piece_place, and no strip vertex has a snap word; (3) the far look's paint and ground terms stop where pieces draw, by the test of today's mask paint with surface.plan_range in place of the corridors' held, and terrain nodes wholly inside the floors' range draw with WAYS = false (the lean pipeline) when the shots prove that nothing changes. At R = 0 no piece draws, the worker builds only the records, and B is the look at every distance. The frame gate is the owner's: no slower than develop, with the pieces, their casters and the lean pipeline, by GP8's clock rule.
Rejected:

  • A test per node for "level 17 without a morph" (rule.morphs): a drawn level-17 node starts at 480 m and reaches 294 m farther, past the 779 m where level 17 starts to morph, so most level-17 nodes in range would draw no piece.
  • Each cell draws as soon as it is built: where a built cell meets an unbuilt one inside the owned region, a floored node meets an unfloored one, and the terrain cracks at their border.
  • The near or far range per piece by distance: near and far share vertices only on locked lines (GP7, section 3), not on piece borders, and GP7 measures a level-18 node's floors against the near range only.
  • A node that draws a coarser page keeps its pieces: the strip's terrain vertices then take the coarser page's heights, and nothing bounds the difference to the 5 cm under GP7's floors.
  • Floors in a texture array: 65 × 65 grids do not tile its 2048-texel layers. A storage buffer reads by vertex_index directly.
  • An atomic count in the fragment stage: a storage write makes the depth test run late, so it counts hidden fragments.
  • One discarding pipeline for all pieces: a discard turns off early depth writes, and on Apple GPUs the hidden-surface removal of the pipeline, which would then draw all the owned ground near the eye.
  • A fragment stage of its own for pieces: it would copy corridor_fragment. Two override constants share it.
  • A per-fragment model look near the eye for R = 0 (variant A): several ms per frame.
  • The page cache near the eye for R = 0: about 2 ms more than the far-only variant (PROPOSAL-shapes.md section 8).
  • No snap on the new path (the previous GP8): the edge lines wobble far from the eye (owner: a showstopper, TrailView ground plan GP8: the far stamp, one stamp function in every page on the new path, no snap #3102).
    Systems: trailview
    Contracts: none
    Size: L (the terrain's vertex stage, its layouts and the Surface uniform; a new draw module with pools, floor slots and the range rule; GP5's plan client gains the payload; a new debug view with a backstop; the count in three test scripts; the snap factor, the paint's stop and the lean terrain pipeline; about 800 to 1,100 changed lines)

Facts

Line numbers from develop 4f8b8b255; GP5 and GP8 can shift them. Paths under builder/web/src/lib/trailview/ unless they start with tests/.

  • The terrain draws one instanced 64 × 64 grid per node. The instance is [x, y, level, 0], 16 bytes (terrain/terrain.ts:882-884); the WGSL Node has unused: u32 (terrain/terrain.wgsl:25).
  • vertex_main makes each vertex with grid_vertex(node.origin + grid * cell, node.level, true); vertex_index is j × 65 + i; it computes the clip position inline (terrain/terrain.wgsl:57-64). No WGSL file uses @invariant.
  • vertex_place slides an odd vertex to the even one by morph = level − lod; place_height at morph ≥ 1 returns the coarser level's height at the even vertex (terrain/drawn-mesh.wgsl:65-89). even = u − ((anchor + u) & (1 << (24 − level))) (:72).
  • MORPH = 0.3 (terrain/lod.ts:10). A level-k vertex morphs where lod < k, past range / 2^(k + 0.3) (terrain/lod.ts:78-90, terrain/drawn-height.wgsl:202-206). At the least factor 4.6, level 17 starts to morph at 779 m at 47° N and at 571 m at 60° N: lengths scale with the metres per unit, which fall with the cosine of the latitude.
  • A level-17 node splits where its nearest point is nearer than range / 2^18 (480 m at 47° N), so a drawn level-17 node lies at least 480 m off; its far corner lies up to 294 m farther (terrain/lod.ts:86-87).
  • distances and lod() include the eye's height above the ground (terrain/lod.ts:65-72, terrain/drawn-height.wgsl:203).
  • select culls while it walks the quadtree: a culled node takes its subtree (terrain/lod.ts:104-127).
  • pages.drawing(node) gives the node of the finest page that draws the node; it is coarser while the node's own page is on its way (terrain/pages.ts:175-178, terrain/terrain.ts:438).
  • Group 0 of the terrain holds the frame, nodes and holes (terrain/terrain.ts:901). Today the vertex stage reads 6 storage buffers in groups 0 to 3 (gpu/layouts.ts); the WebGPU default limit is 8.
  • Surface is 416 bytes; levels is its last field (terrain/terrain.ts:62, terrain/drawn-height.wgsl:9-25).
  • Detailed nodes draw with cull mode none, far nodes with back; depth greater, reversed Z, depth32float, near plane 0.1 m (terrain/terrain.ts:932-945, resolve/targets.ts:3, frame/camera.ts:13).
  • Only the portal pipeline discards: a discard turns off the hidden-surface removal of tile-based GPUs such as Apple's (terrain/terrain.wgsl:33-36).
  • Terrain.draw: structures, corridors, opaque water, the terrain nodes (holed, detailed, far), then ribbons (terrain/terrain.ts:579-601).
  • The selection culls a node by its page's height range ± 50 m (terrain/terrain.ts:52, :799-804). A corridor piece culls by its own box: its node's square grown by over, between low and high (:806-810).
  • Quality falls one step in a frame with a shortage and rises after CALM = 120 calm frames with room (terrain/terrain.ts:49, :451-457); finest = FINEST − max(0, quality − steps) (:792-797).
  • Corridors: RANGE_M 600, pools of 27 and 20 MiB, 256 draws. They draw before the terrain and cast into the shadow map (roads/roads.ts:18-34, :180-201). Meshes build only after the eye field drew (terrain/terrain.ts:448).
  • Pool has fixed buffers; a mesh that finds no room drops, the last of the frame's list first (structures/pool.ts:4-60).
  • corridor_fragment discards the paint's dither share, the detail fade and every all-ground pixel (roads/corridor.wgsl:214-220, :235-237). The corridor vertex moves toward the eye by 6 cm plus 0.3 % (:113-116). A debug view clips corridors away (:146-148). The pipeline sets BAND = 1, which turns off the road paint in ground_colour (roads/roads.ts:293, ground/look.wgsl:40, :673).
  • Casters draw with CASTER_PRIMITIVE (cull none) and CASTER_DEPTH (light/shadow.ts:53-54).
  • debug=cracks clears the scene to red and skips the sky (engine.ts:617-632). flight.mjs --cracks N counts exact red pixels of a screenshot every Nth frame, and hides #hud only for --cracks (tests/browser/trailview/flight.mjs:99-102, :124, harness.js:133-144); such a flight must look down on known terrain (README.md:103-104).
  • A debug view's resolve copies the scene colour unchanged, so channels of 0 and 1 reach the PNG as 0 and 255 (resolve/resolve.wgsl:116-118).
  • DEBUG_VIEWS order is the shader's debug index (view.ts:9); the terrain fragment picks its branch at terrain/terrain.wgsl:121.
  • Varyings uses locations 0 to 5 in terrain/unknown.wgsl and terrain/unknown-clip.wgsl; only terrain/terrain.wgsl includes them.
  • waitForIdle() waits while terrain.roadsLoading (engine.ts:330). shoot.mjs prints redPixels for a debug=cracks view (tests/browser/trailview/shoot.mjs:62).
  • The TrailView README has 794 words of its 800-word cap (obc prose).
  • GP8 writes the snap words from the model's core distance (paved area ∪ paved shoulder, shoulder, verge), and its far look (B) reads B's paint texture, the feature mask and the move-and-share texture (GP8, sections 1 and 2).
  • The snap moves a mesh vertex onto an edge within SNAP_TEXELS = 3 texels of its level inside, SNAP_OUT_TEXELS = 3.75 outside (terrain/stamp.wgsl:59-62): 3.05 m at level 17, 1.52 m at level 18 outside.
  • GP7's inner border lies in terrain faces, a level-17 cell or more from other faces (GP7, section 3).
  • Today's paint draws where the corridors do not: paint_share is 1 past masks.held (terrain/features.wgsl:191-194).
  • The road terms of the look cost every detail fragment: B with no paint against B is the most that a lean terrain pipeline inside the mesh range can save, about a third of B's share (shapes/budget/).
  • B with fixed ground terms and the one-walk stamp, past 560 m, before pieces: a little faster than develop's whole road cost on both flights (farb/cost/INDEX.md). The pieces replace the corridors.
  • B's quality nearer than 560 m is not measured since GP8's fix of the ground terms; before it, B near the eye showed the stair faults of the 8-bit mask (cost/page/page-vs-fragment.png).
  • A stamp job with heights can spike on dense near pages (p95 about +1.7 ms per job, heights/round5/).
  • Evidence paths are on timosPC under /media/timo/OSM1/tmp/ground-plan/one-model/.

Design

1. Constants

In plan/pieces.ts unless the row says otherwise. GP9 adds no rule-table field: these are viewer budgets, not rules of the ground. GP5's constants of Plan stay; section 4 names the one that changes.

Name Value Why
RANGE_M 600 m, LOD metric the epic's range (section 8), as today's corridors
FADE_M 40 m the epic's fade band, where the floor is off; also the margin before level 17 starts to morph. Both must exceed TRIANGLE_M
TRIANGLE_M 10 m the reach of a terrain triangle with its morph: a level-17 diagonal is 4.6 m, and a level-18 vertex slides by at most 1.63 m
VERTEX_BYTES 24 MiB GPU pool of piece vertices; with the floors, today's 27 MiB
INDEX_BYTES 20 MiB GPU pool of piece indices, as today
FLOOR_SLOTS 160 floor grids of 4225 f32 (16,900 bytes), 2.6 MiB. All round at 47° N, at most about 120 owned nodes lie within 600 m (108 level-18 nodes of the 27 split level-17 nodes, and about 12 level-17 nodes from 480 to 600 m); a third more holds a turn
FLOOR_NONE 1e30 m a vertex without a floor; a shader never sees NaN
DRAWS 256 piece parts per frame at most; within 600 m all round at most 156 nodes hold pieces
BACKSTOP_M 20 m in terrain/terrain.wgsl: the backstop's depth under the floored terrain
return frames CALM (120) exported from terrain/terrain.ts (section 10)

2. What GP9 takes from GP5, GP7 and GP8

From GP7, through GP5's worker, per CellKey (a level-16 node) once built with MeshOptions { near }:

  • Piece[]: per level-18 node of the cell that the owned region touches, one or more parts: the node, origin (the north-west corner of the node, whole world units), part, low_m, high_m, over (the world units that its vertices reach past the node's square), the vertices (GP7's 20-byte vertex, the position in 1/16 world unit about origin), 16-bit indices, and the ranges near, far, strip and casters (each {first, count}). With near: false, the near range is empty.
  • Floors: per level-18 and level-17 node of the cell that the owned region touches: 4225 heights in the terrain's vertex order (j × 65 + i, i east and j south from the node's north-west corner), or none per vertex. GP9 decodes GP7's codes to f32 metres and uploads none as FLOOR_NONE.

GP9 relies on these properties of GP7's output (GP7's checks prove them): (a) every terrain vertex of the strip (on ∂R and on the middle ring) carries the terrain-vertex flag, and its position is its exact level-18 grid point; every other vertex keeps its stored position and height; (b) every face turns counter-clockwise from above, and a vertical face turns its front to its lower side; (c) the near and far ranges hold the same vertices on the locked lines (cell borders and the inner border), so two cells that draw different ranges meet vertex for vertex; (d) the floor lies at least 5 cm under every mesh triangle that can draw over it, by GP7's draw rule (GP7, section 9), the strip at morph 0 and 1 included; (e) ground faces carry MAT_GROUND in the look word; (f) each node with a piece has a level-18 floor grid, and its level-17 parent a level-17 grid. GP9 checks (f) when a cell arrives; a cell that breaks it gets GP5's failed record.

From GP8: under the switch, every page holds the model's heights, feature mask, move-and-share texture and B's paint (and the far layers on the high tier); no corridor draws; the snap pass writes the snap words from the core distance. If Roads still runs under the switch, GP9 makes Terrain build no Roads then. GP8's WAYS override, --toggle ways, --lean and its clock rule are GP9's test tools.

From GP5: the switch flag, which Terrain takes at construction, and class Plan (plan/plan.ts) with its wanted cells, records, dispatch, loading and stats.ready (GP5, section 6).

3. The range R

planRange(rule, on) = on ? min(RANGE_M, morphStart(rule, 17) − FADE_M) : 0, with morphStart(rule, level) = rule.range / 2^(level + MORPH), in the LOD metric (the distance includes the eye's height). At factor 4.6, R is 600 m at 47° N and 531 m at 60° N.

Invariant: every vertex of a triangle with a pixel within R lies within R + TRIANGLE_M < morphStart(17), so level 17 does not morph there. So over a level-17 node the strip, made of level-17 grid vertices (section 8), is exactly the terrain's level-17 cell row.

Decision: the epic's "level 17 without a morph" holds per point, by R. The node test is drawable(node) (section 6, step 5).

4. Cells (GP5's Plan, changed)

GP9 keeps GP5's Plan: the wanted cells (within the build range by distance, then the ahead band of AHEAD_M within AHEAD_COS), the records in priority order within KEPT_BYTES, QUEUED, KEEP_M, failed, empty and loading. So the cells inside R always come first in the requests and in the budget. GP9 changes these rules:

  • update gains range and full. range = rule.finest ≥ 17 ? planRange(rule, true) : 0 replaces BUILD_M. It is the range as if the pieces were on, so a return after the hysteresis finds the cells built. With range = 0 no cell is wanted, the ahead band too, and the records stay until they pass KEEP_M.
  • The build message gains near: rule.finest ≥ 18, which goes to MeshOptions. A built record keeps its near flag, its pieces and its floors; their bytes count in KEPT_BYTES.
  • While rule.finest ≥ 18, a wanted record without near counts as missing for the dispatch. It stays until its new build arrives, as GP5 does for a debug mesh.
  • A record that fails check (f) is failed.
  • full is true when R > 0 and R_eff = R in the last frame. stats.ready ends at the first frame in which every wanted cell within range has a record and full holds. So after a jump it is the time to a complete mesh.
  • Terrain.roadsLoading returns roads.loading || plan.loading, so waitForIdle() waits for the cells within range.

A cell is built in a frame when its record is empty, or built and passing (f), and, if one of its candidate nodes (section 6) is level 18, built with near.

5. Pools and floor slots (plan/pieces.ts)

  • The vertex and index pools are one Pool of VERTEX_BYTES and INDEX_BYTES. Each frame the pool takes the piece parts of built cells whose node's nearest LOD distance is under R, ordered by that distance alone, so a turn never reorders the pool. Under pressure the farthest parts drop first.
  • The floor buffer holds FLOOR_SLOTS × 4225 f32 (read-only storage, vertex stage). A slot holds one floor grid: a node and its level. A candidate node that needs a floor (section 6, step 5) keeps its slot, or takes the free slot that has gone longest without use. A slot is free when no candidate node of this frame uses it. write() uploads the grid of a newly taken slot (16,900 bytes).
  • attach() makes the pools and the floor buffer on a new device; every piece and floor then uploads again from the records.

6. The effective range R_eff (each frame, after the selection)

The candidates are the leaves of rule.split within R, without the frustum cull: from each level-16 node with nearest distance under R, split while rule.split says so, and keep each leaf with nearest distance d < R. So no step below depends on the view direction.

  1. R = planRange(rule, on) (section 10 sets on).
  2. R_eff = R.
  3. For each wanted cell with nearest distance d < R that is not built (section 4): R_eff = min(R_eff, d).
  4. For each entry of the pool's list (section 5) that the pool dropped, or that comes after the first DRAWS entries: R_eff = min(R_eff, d).
  5. Take the candidates in order of d, and stop at the first with d ≥ R_eff. For each: if drawable(node) is false, R_eff = d and stop. If d < R_eff − FADE_M and its cell holds a floor grid for this node and level, it needs a slot; if no slot is free, R_eff = d and stop. drawable(node) is true when the node's level is 17 or 18 and pages.drawing(node) is the node itself (its own page is resident). GP10 adds its conditions to this predicate only, so every refusal cuts R_eff.
  6. floorRange = max(0, R_eff − FADE_M).
  7. The word of each drawn terrain node: its slot + 1 if it is a candidate with a slot from step 5 and d < floorRange, else 0.
  8. The draws: for each candidate with d < R_eff, the pieces of its square (level 18: its own; level 17: those of its four children), each part with the near range if its cell has a level-18 candidate, else the far range, then its strip. The instance is [the part's origin x, origin y, the candidate's level, 0] (sint32x4, whole world units). Over a level-17 candidate, each child's part takes its own origin. A part draws when its box meets the frustum: its node's square grown by over, between low_m and high_m, by the corridors' inFrustum. It draws in the pieces pipeline when its box lies within floorRange (its node's far LOD distance plus over in metres is under floorRange), else in pieces cut (section 8).
  9. changed is true when R_eff, the draws or the words differ from the last frame; Terrain.newContent takes it.

Steps 2 to 7 are one pure function, effectiveRange(R, candidates, cells, pool, slots, drawable), so pieces.test.ts tests it without a GPU.

Invariant I1: every candidate within R_eff is drawable, lies in a cell built with the range that it draws, has its parts in the pool and among the first DRAWS, and has its floor in a slot if it is nearer than floorRange. So a pixel within R_eff shows a piece or plain terrain, never a sunk floor without its piece. A node border inside R_eff joins two built cells. Next to a node past R_eff, the shared border lies past R_eff, so its vertices are not floored on either side.

Invariant I2: R_eff, the slots and the words do not depend on the view direction. Only the frustum cull of step 8 does.

This is the defined fallback for every case that cannot draw: a cell not built yet, a failed cell, a full pool, too many draws, too few slots, a page on its way. The mesh ends at R_eff, and past it the terrain shows GP8's far look of the ways. No face covers the case.

7. Floors in the terrain (terrain/terrain.wgsl)

  • Override PLAN: bool (the switch) on every mesh pipeline. Node.unused becomes floor: the slot + 1, or 0.
  • Group 0 binding 3: floors: array<f32>, read-only storage, vertex stage: 7 of the 8 storage buffers.
  • Surface gains plan_range: f32 and floor_range: f32 after levels: 432 bytes. Terrain.write fills them with R_eff and floorRange.
  • vertex_main: grid_vertex(u, level, snap) with the snap factor snap = 1 without the switch, and with it snap = select(smoothstep(floor_range, plan_range, d), 1.0, plan_range <= floor_range), d the LOD distance of the vertex's grid point before the morph. At R = 0 (plan_range = floor_range = 0) the smoothstep is not defined, and the select gives 1. So no vertex within floor_range moves, and the snap is full past R_eff. Then, if PLAN, node.floor ≠ 0, the height is known, and length(vec3f(lod_space(vec2f(u)), surface.above)) < surface.floor_range: f = floors[(node.floor − 1) × 4225 + vertex_index]; if f < 1e29, the height becomes min(height, f) and floored = 1.
  • The test reads the grid point u before the morph, so a vertex keeps its floor through its morph; the floor holds at morph 0 and 1 (GP7, property d).
  • The varying floored (location 6, in both Varyings variants) is read only by the owned view.
  • The holed pipeline shares vertex_main, so the floor holds at tunnel portals too. There is no discard, no clip distance and no cover pass, and the terrain stays one instanced draw (the epic).

8. The pieces' draw (plan/piece.wgsl, Pieces.draw)

  • plan/piece.wgsl includes ../terrain/drawn-mesh.wgsl and ../roads/corridor.wgsl, and adds PieceIn (GP7's vertex and the instance), piece_place, piece_vertex and piece_caster.

  • piece_place: g = origin + position / 16, with origin from the instance. For a terrain vertex, g is its level-18 grid point in whole world units (the division is exact). Over a level-18 node it takes grid_vertex(g, 18u, snap); over a level-17 node, grid_vertex(even, 17u, snap) with even = g − ((anchor + g) & vec2i(64)); snap is the factor of section 7. If that height is unknown, the vertex keeps its stored height, so the strip still closes against the interior. Any other vertex takes its stored position and height.

  • So a terrain vertex of the strip is the terrain's own vertex of the node's level at that grid point, made by the same function with the same integer input. Over a level-18 node it slides and morphs as the terrain's does; over a level-17 node it is the level-17 grid vertex, which is the strip "at morph 1". Where a level-18 node meets a level-17 node, the level-18 side has morph 1 (CDLOD), so both give the same vertex.

  • Same clip position: terrain/drawn-mesh.wgsl gains render_point(s: vec2f, z: f32, curvature: f32) -> vec3f: p = vec3f(to_render(s), z), then p.z −= curvature × dot(p.xy, p.xy). vertex_main and piece_vertex both call it, with z = h − frame.eye, and both write clip = frame.view_proj * vec4f(p, 1.0). @invariant goes on @builtin(position) in both Varyings variants and in CorridorOut; the backstop uses vertex_main. Invariant: a terrain vertex of the strip and the terrain's vertex at the same grid point have bit-equal clip positions, so ∂R has no pixel crack, and a strip triangle that equals a terrain triangle wins the greater depth test by its draw order (GP7, section 6).

  • piece_vertex has no bias. It writes CorridorOut from GP7's look word, across and along. In a debug view other than off and owned it clips away, as corridors do, so those views show the terrain as it draws.

  • corridor_fragment gains the overrides PIECE: bool and CUT: bool (both false for corridors). With PIECE: it has no paint dither and no detail fade; an all-ground pixel draws corridor_ground instead of a discard (no terrain stands under it to draw it); in owned it returns a lit blue-grey. With PIECE and CUT: it discards a pixel whose LOD distance is at least surface.plan_range. With PIECE and not CUT, the shader has no discard, as the terrain's pipelines without HOLES.

  • Two piece pipelines, pieces (CUT = 0) and pieces cut (CUT = 1): fragment corridor_fragment with BAND = 1 (no road paint on the mesh's ground faces) and PIECE = 1; triangle list, cull back; depth greater with write. Only the parts whose box reaches past floorRange draw in pieces cut: the nodes that cross the last FADE_M of the range. GP10's dither goes into pieces cut only.

  • Pieces.draw runs in Terrain.draw where corridors draw today, before the opaque water and the terrain: first pieces, then pieces cut, each nearest first. The early depth test then rejects the terrain under the pieces.

  • Snap at the strip: piece_place passes the same snap factor to grid_vertex for a terrain vertex of the strip, so a strip vertex and the terrain's vertex at the same grid point stay bit-equal. Within floor_range the factor is 0. Past it, the strip's snap words are 0 by construction: GP7's inner border lies a level-17 cell (3.25 m) or more from every non-terrain face, and every slope band reaches at least feather_min_m (1 m) past the core, so a strip vertex lies at least 4.25 m from the core outline, past the snap's reach (3.05 m at level 17, 1.52 m at level 18). The count of strip vertices with a snap word ≠ 0 proves it (target 0).

9. Casters

piece_caster takes the same piece_place and the sun's frame, with CASTER_PRIMITIVE and CASTER_DEPTH. It draws the casters range of every part in the pool whose node lies nearer than R_eff, in view or not, in both cascades (Terrain.cast). Pieces.arrived is the distance of the nearest part whose casters draw for the first time in this frame; Terrain.reshaped takes it as it takes roads.arrived.

10. Lower quality and the hysteresis

  • on becomes false in the first frame with rule.finest < 17. It becomes true after CALM frames in a row with rule.finest ≥ 17. At the start it is true.
  • With finest 17, every node within 959 m is level 17: pieces draw their far range over level-17 nodes, the strip is made of level-17 grid vertices, and the level-17 floors hold the terrain. Cells build with near: false.
  • From finest 17 back to 18, a cell built without near is not built in a frame in which it has a level-18 candidate (section 4), so R_eff stops before it until its new build arrives.
  • With finest 16, R = 0: no piece and no floor. The worker builds only the records of each cell (GP3's candidates and GP3R's encoders; no overlay, no heights on the CPU, no mesh), and B is the look at every distance (GP8). The snap is full everywhere. This is the phone's lowest step; its build time is measured (Verification), and the owner accepts B near the eye or asks for more (question 1).
  • Why CALM: the quality rises after CALM calm frames and can fall again a few frames later. The pieces then wait CALM frames more, so a quick fall never shows them.
  • The address parameter finest (16 to 18, default 18) caps the rule's finest level (terrain/terrain.ts:796), so shots and counts show the phone's path on the GTX box.

11. What GP9 gives the other items

  • GP10: surface.plan_range and surface.floor_range, the predicate drawable and invariants I1 and I2. "Exactly where pieces draw" is then the test per pixel length(vec3f(s, surface.above)) < surface.plan_range over the candidates. GP10 adds the dither over the last FADE_M in pieces cut; until then the mesh ends with a hard edge at R_eff.
  • GP11: the piece pipelines. piece_vertex gives CorridorOut; GP11 replaces the look (normals, ground faces and the strip with the terrain's own normal and facet).
  • GP12 to GP17: the casters range, the pools and the draw order.
  • GP8 and GP10: the snap factor; surface.plan_range as the far look's stop.

12. The ownership count: debug=owned

  • DEBUG_VIEWS gains owned (index 11; OWNED_VIEW in terrain/terrain.wgsl and roads/corridor.wgsl).
  • Engine: in owned the scene clears to black, and the sky, the instances and the houses do not draw. Terrain.draw draws no structures and no water in owned.
  • In owned, all detailed nodes draw with the plain mesh pipeline (no portal holes: an open portal is no crack). Then the backstop pipeline (override BACKSTOP, cull none) draws every drawn node again, with its floor, BACKSTOP_M lower, in magenta (1, 0, 1).
  • The terrain fragment in owned: green (0, 1, 0) where floored > 0 and the pixel's LOD distance is under surface.floor_range − TRIANGLE_M, else lit grey as in cracks. Why: outside R no vertex is floored, so floored > 0 marks every terrain triangle with a corner inside R, the outer sub-cells of the strip included. Within floorRange − TRIANGLE_M, each such triangle has all its corners within floorRange, so each corner off ∂R is floored, and a green pixel is terrain that rose through the mesh. The band from there to R_eff holds triangles with unfloored corners by design; it is GP10's fade. Pieces: lit blue-grey.
  • Why the backstop finds every crack: the eye stands above the drawn surface, inside the convex box of known heights. The drawn surface (pieces with their vertical faces, and the terrain) is continuous where it has no crack, and the backstop lies under it everywhere. A ray to a backstop point under the surface crosses the surface first, unless it passes through a crack. So a magenta pixel is a see-through crack at any pitch and at any distance, with or without sky in the view.
  • Counts: magenta pixels are cracks, green pixels are inside. The target of each is 0.
  • tests/browser/trailview/harness.js: colourPixels(page, colours) counts exact colours in one screenshot; redPixels becomes its red case.
  • flight.mjs --owned N loads each flight with debug=owned, hides #hud as --cracks does, and prints owned: {cracks: [...], inside: [...]} every Nth frame. It excludes --cracks.
  • shoot.mjs hides #hud and prints owned for a view with debug=owned, and the plan stats for every view.
  • If neither GP5 nor GP8 added it, both scripts gain --query Q, a string appended to every view, for &ground=plan.
  • Limits: the eye must lie inside the box of known heights (true for the fixture's flights and views); count by day, as the flights do, because night light pools tint pixels.

13. Edge cases

  1. Empty cell (no way or water near): an empty record, no piece and no floor; no request if no feature tile covers it (GP5).
  2. Cell at the edge of the data: the terrain drops each triangle with an unknown vertex; a strip vertex with an unknown drawn height keeps its stored height, so the strip closes against the interior, and no terrain stands beside it to crack against.
  3. Eye high above the ground: every LOD distance exceeds R, so no candidate, no floor, no request.
  4. Finest 17 and finest under 17: section 10.
  5. Level-18 node in its morph band (390 to 480 m): the strip's outer vertices slide and morph with the terrain's, through the same code.
  6. Level-17 node that morphs past R: its pixels past R discard in pieces cut; its vertices within R + TRIANGLE_M do not morph.
  7. Node that draws a coarser page: drawable is false, so R_eff stops before it until its own page arrives, and the far look and the snap act there (they read the coarser page's mask). The walk's range gate measures how often this happens.
  8. Full pools, too many draws, too few slots, full budget, failed cell: R_eff shrinks (section 6); stats count each case.
  9. A jump: every cell is new; R_eff grows from 0 as cells arrive, nearest first.
  10. Device loss: section 5.
  11. Two cells: a cell border inside R_eff joins built cells (I1), whose meshes meet vertex for vertex on the locked border, also where one draws near and the other far (GP7, property c).
  12. Owned region at the border of a cell past R: every point of that border lies past R_eff; its vertices are not floored.
  13. Mesh heights outside the node's page range: each part culls by its own box (section 6, step 8); the terrain node keeps its page box.
  14. Steep ground and deep cuts: a floor can lie metres under the terrain; the backstop lies BACKSTOP_M under the floored terrain, so it stays under the mesh.
  15. A cell switches from far to near when its first node splits into level 18: the whole cell at once, by GP7's rule, which its floors assume.
  16. Eye under a piece (a deep fill, while the probe still reads the pages): cull back then shows the floored terrain from under the mesh. GP10 measures the readers and the probe.
  17. Without the switch: section 14.
  18. A node that crosses R_eff: its terrain draws the far look per pixel past plan_range (section 15) and nothing nearer.
  19. A page whose blocks of the model's records have not arrived (GP8, section 3; not GP5's cell records): the coarser page draws, and the far look shows no way there until the blocks arrive and the page is stamped again. Inside R_eff every cell is built, so its block exists. The wait is part of plan.ready.

14. The old path

Without the switch, PLAN = 0, BACKSTOP = 0, PIECE = 0 and CUT = 0 fold every new branch away, the node words stay 0, plan_range and floor_range stay 0, and binding 3 holds a 16-byte buffer. Pieces and the plan do not exist. render_point and @invariant change no value, so shots without the switch are byte-equal to the merge base's. WAYS and the snap factor fold away without the switch (the factor is 1).

15. The far look stops where pieces draw

  • Paint and ground terms: on the new path, paint_share reads surface.plan_range in place of masks.held: B paints and shapes the look only where a pixel's LOD distance is at least plan_range. That is the test of today's mask paint, with the pieces in the corridors' place. GP10 replaces the hard edge with the dither of its fade band.
  • The lean terrain pipeline: a terrain node whose far LOD distance is under floor_range draws with WAYS = false. Its pixels lie under pieces, or on plain terrain outside the owned region, where no way has a road term: outside every non-terrain face the height is the plain terrain, GP7's owned region holds every slope band, and GP8's shaped zone ends at the slope layer (GP8 section 2; the class form with its fixed 10 m reach would reach past a narrow slope band and fail this test). Proof: shots of every view of Pictures with and without the lean pipeline differ in no pixel. If a pixel differs, a way is missing from the mesh: the PR names it, and the lean pipeline stays off until it is fixed.

Plan

  1. terrain/lod.ts: export morphStart(rule: Rule, level: number): number = rule.range / 2 ** (level + MORPH).
  2. terrain/terrain.ts: export CALM. Add the finest cap to rule().
  3. plan/plan.ts (GP5's Plan): range in place of BUILD_M, near in the build message and the record, the payload in KEPT_BYTES, check (f), the near rule of the dispatch, full and the end of stats.ready (section 4); cell(key) returns the record or undefined.
  4. plan/pieces.ts (new): the constants of section 1; planRange(rule, on), candidates(rule, eye, range) and effectiveRange(...) (pure); class Pieces with attach(gpu, frames), update(eye, rule, drawn: Node[], plan, pages, frustum): { range, floorRange, words: Uint32Array, changed }, write(), draw(pass, surface, ground, features), cast(pass, surface), arrived, floors: GPUBuffer, stats: { range, target, cells, pieces, vertices, triangles, floors, draws, cut, dropped, short, waiting, failed }. Its pipelines: pieces and pieces cut (layout: the corridors' frame group, surface, ground, features; GP7's vertex buffer and the sint32x4 instance) and piece casters (frame, surface).
  5. plan/piece.wgsl (new): section 8.
  6. terrain/drawn-mesh.wgsl: render_point.
  7. roads/corridor.wgsl: the overrides PIECE and CUT, OWNED_VIEW in corridor_fragment, @invariant in CorridorOut (section 8).
  8. terrain/drawn-height.wgsl: plan_range and floor_range in Surface.
  9. terrain/unknown.wgsl, terrain/unknown-clip.wgsl: @invariant on clip, and @location(6) floored: f32 in Varyings (and Fragment).
  10. terrain/terrain.wgsl: Node.floor, binding 3, the overrides PLAN and BACKSTOP, BACKSTOP_M, OWNED_VIEW, render_point and the floor in vertex_main, the backstop and owned branches in fragment_main.
  11. terrain/terrain.ts: SURFACE_BYTES = 432 and the two fields; binding 3 in the mesh layout; meshPipeline(gpu, detail, holes, backstop) with PLAN from the switch (cache the pipelines per switch value); under the switch make Pieces, give Plan.update its range and full, call Pieces.update after the drawn nodes are sorted, write the node words, draw pieces before the opaque water, cast them, add plan to TerrainStats, roadsLoading, reshaped, failed and newContent; the owned draw with the backstop.
  12. view.ts: owned in DEBUG_VIEWS; the finest parameter (16 to 18). engine.ts: pass finest to Terrain; in owned, clear black and skip the sky, the instances and the houses.
  13. tests/browser/trailview/harness.js, flight.mjs, shoot.mjs: section 12.
  14. tests/browser/trailview/smoke.test.js: one @gpu test (Verification).
  15. plan/pieces.test.ts (new), plan/plan.test.ts and view.test.ts: Verification.
  16. README.md of TrailView: owned in the debug row, a finest row, --owned N in the flight line; shorten the --cracks sentence so that the file stays within 800 words.

Additions to the steps above:

  • Step 4 (plan/pieces.ts): at R = 0, the plan asks the worker for the records only.
  • Step 5 (plan/piece.wgsl): the snap factor in piece_place; the count of strip vertices with a snap word.
  • Step 10 (terrain/terrain.wgsl): the snap factor in vertex_main; paint_share with surface.plan_range on the new path.
  • Step 11 (terrain/terrain.ts): the lean pipeline (WAYS = false) for nodes inside floor_range, cached per switch value.
  • Step 13: flight.mjs --toggle ways skips the pieces and their casters on odd frames (GP8's toggle).

Files

  • builder/web/src/lib/trailview/plan/pieces.ts: create: candidates, pools, floor slots, R, R_eff, draws, culling, casters, hysteresis, stats.
  • builder/web/src/lib/trailview/plan/piece.wgsl: create: piece_place, piece_vertex, piece_caster.
  • builder/web/src/lib/trailview/plan/pieces.test.ts: create: the range, the candidates, effectiveRange with the node words, the hysteresis.
  • builder/web/src/lib/trailview/plan/plan.ts, plan/plan.test.ts: change: range, near, payload, check (f), full, ready.
  • builder/web/src/lib/trailview/plan/plan-worker.ts: change: near to MeshOptions, the pieces and floors in the result (if GP7 did not add them).
  • builder/web/src/lib/trailview/roads/corridor.wgsl: change: PIECE, CUT, OWNED_VIEW, @invariant.
  • builder/web/src/lib/trailview/terrain/terrain.wgsl: change: floor, render_point, overrides, backstop, owned.
  • builder/web/src/lib/trailview/terrain/unknown.wgsl, terrain/unknown-clip.wgsl: change: @invariant, floored.
  • builder/web/src/lib/trailview/terrain/drawn-mesh.wgsl: change: render_point.
  • builder/web/src/lib/trailview/terrain/drawn-height.wgsl: change: two Surface fields.
  • builder/web/src/lib/trailview/terrain/terrain.ts: change: section 7 to 12 wiring, CALM export, finest cap.
  • builder/web/src/lib/trailview/terrain/lod.ts: change: morphStart.
  • builder/web/src/lib/trailview/view.ts, view.test.ts: change: owned, finest.
  • builder/web/src/lib/trailview/engine.ts: change: finest, the owned pass.
  • builder/web/tests/browser/trailview/harness.js, flight.mjs, shoot.mjs: change: the count, --owned, the HUD, --query if absent, plan stats.
  • builder/web/tests/browser/trailview/smoke.test.js: change: one @gpu test.
  • builder/web/src/lib/trailview/README.md: change: the address and flight lines. Public docs (docs/content/) do not change.
  • builder/web/src/lib/trailview/ground/look.wgsl: no change (the WAYS pipeline is GP8's; GP9 uses it). terrain/features.wgsl: change: paint_share.
  • builder/web/src/lib/trailview/plan/plan-worker.ts: change: the records-only build at R = 0.

Verification

  • Checks:
    • npx vitest run src/lib/trailview (in builder/web). pieces.test.ts asserts: planRange equals min(600, morphStart(17) − 40) for factors 4.6 and 8 with the eye at 1.6 m and at 300 m, is 0 when off, and rule.lod is at least 17 at every distance up to R + TRIANGLE_M; candidates equals select's leaves within R with no cull, for any view; effectiveRange gives R when all is built, and the distance of an unbuilt cell, of a cell built without near that has a level-18 candidate, of a dropped part, of the first part past DRAWS, of a node that is not drawable and of a node without a free slot, and the word 0 for each node at or past floorRange; the hysteresis stays off for CALM − 1 frames at finest 17 after a frame at 16, and turns on at the next. plan.test.ts asserts that range = 0 wants no cell, and that a record without near counts as missing at finest 18 and as present at finest 17. view.test.ts asserts the round trip of finest=17 and debug=owned.
    • npm run check and npm run check:wgsl: every new and changed WGSL file composes with its includes.
    • flock /tmp/trailview-gpu.lock npm run test:trailview (in builder/web/tests/browser, on the GTX box): the new @gpu test opens VILLAGE and the walk's start view with &ground=plan&debug=owned and asserts terrain.plan.pieces > 0, cracks = 0 and inside = 0. CI skips @gpu, so the pull request reports this local run.
    • obc test affected --base origin/ground-plan and obc prose --check (the README stays within its cap).
    • The count of strip vertices with a snap word ≠ 0: 0 in every view and flight frame. The lean pipeline's pixel test (section 15).
  • Measurements (GTX 1660 SUPER, flock /tmp/trailview-gpu.lock, from builder/web/tests/browser):
    • Frame gate, low tier first: node trailview/flight.mjs --gpu --toggle ways, walk and valley-drive, 2560 × 1600, by GP8's clock rule (develop, new, develop, new in one session; the median clock per run; shares scaled to the session's median clock). Runs: develop (the old path of the same build, which is pixel-equal to develop) and &ground=plan&tier=low. The road share = terrain (on − off) + shadow and far-shadow (on − off) + the stamp per frame. Gate: on both flights, the new path's scaled road share is at most develop's scaled share plus the spread (owner: "about as fast as develop, ideally a little faster"). Then the high tier, the same runs and the same gate; if only the far layers break it, the owner decides with GP8's number. Report the parts: pieces, their casters, B past R_eff, the stamp, and the saving of the lean pipeline; the p95 shares and the stamp's p95 per frame (question 3).
    • R = 0: the same toggle with &ground=plan&finest=16, against develop at finest=16. Gate: the same rule.
    • Discard cost: one walk run with the switch and every part in pieces cut (a local change, not committed) against the run above. Report the terrain pass difference; it is the cost that the second pipeline saves on the GTX.
    • Old path pixel-equal: on the merge base of ground-plan and on this branch, each built with npm run pretest:trailview: flock /tmp/trailview-gpu.lock node trailview/shoot.mjs --gpu --views FILE --width 1920 --height 960 --out DIR for the views of Pictures without the switch; cmp each pair of PNGs. Gate: every pair is byte-equal. If two shots of the base differ for a view, shoot the base twice; that view's gate is the base-to-base difference.
    • Count: node trailview/flight.mjs --gpu --query '&ground=plan' --owned 10, and again with '&ground=plan&finest=17'. Gate: every cracks and inside value is 0 in the walk and valley-drive flights.
    • Pools: the largest pool use and dropped, short, failed and the draws over the flights and the views below. Gate: dropped = short = failed = 0, draws at most DRAWS, and vertex pool, floors and index pool together within today's 47 MiB.
    • Range: the share of frames with range < target after the first full range, with the cause (waiting for a page, an unbuilt cell, a pool). Gate: 0 in the walk flight; report the valley drive, whose flight moves at about 245 m/s.
    • Jump: plan.ready that shoot.mjs prints per view of the picture list below. Gate: the largest is at most 2,000 ms (the epic's budget), or the owner accepts the measured times.
    • R = 0 build: the records-only build per cell, native and WASM, and the fill after a jump at finest=16 on GP5's timing page.
    • Phone (the owner runs it; the PR gives the link and the steps): GP5's timing page at finest=16 and finest=17, GP8's pools on the low tier (records, paint, move and share) against the phone's memory, and one far view. The PR proposes the low tier's RECORDS_MB from it (GP8 question 3).
  • Pictures (node trailview/shoot.mjs --gpu --width 1920 --height 960, with and without --query '&ground=plan', on one slider page):
    • At eye height and oblique (road-views.json): v01-stairs to v17-sharp-corners, i07-hairpin-eye, d-hairpin-15m, hp-15m, side-eye, b45-bridge-junction.
    • From above (road-views-top.json of GP0): the -top views of v01 to v17, i07-hairpin, d-hairpin, hp and b45-bridge-junction.
    • The same views with --query '&ground=plan&debug=owned': the owned region in blue-grey, no magenta, no green; shoot.mjs prints the counts.
    • The phone's path, '&ground=plan&finest=17': v06-road-path-broken, v12-good-town-junctions, v17-sharp-corners, i07-hairpin-eye and their -top views.
    • Finest 16 (R = 0) at v06-road-path-broken, v12-good-town-junctions, v17-sharp-corners, i07-hairpin-eye, main-road-15m and their -top views: B as the near look, no piece, no hole. The owner judges whether B near the eye is good enough for the lowest quality step.
    • The end of the range: valley-eye and main-road-eye with the switch: the mesh ends at R_eff with no gap (a hard edge until GP10).
    • The owner looks for: no tear, no crack, no sliver and no terrain through the mesh, at 2×, from above and at eye height; no step or seam in the geometry where the mesh meets the terrain at the strip; the same ground at level 17 as at level 18, with coarser terrain only outside the owned region. The look on the mesh is today's corridor look with facet normals; GP11 changes it.
    • main-road-15m, valley-eye, t-tertiary-eye: the snap acts only past the floors; no wobble of the far edge lines, and no seam where it starts.
    • v17, i07 and d-hairpin on the near mesh with GP6's heights.legs = ramp and = wall (W with the leg split of TrailView ground plan GP3 follow-ups: open decisions and review items #3132): the views for the owner's question 4.

Done when

  • The pull request into ground-plan has merged with green CI and resolved review findings.
  • The checks pass, the @gpu test included (local run reported).
  • cracks = 0 and inside = 0 in the walk and valley-drive flights at level 18 and at level 17, and in every view of the picture list; no strip vertex has a snap word.
  • The frame gate holds on both flights, low tier and high tier, or the owner decides on the measured numbers; the R = 0 gate likewise.
  • The pool gate and the walk's range gate hold, and the discard cost is reported; the largest plan.ready is at most 2,000 ms, or the owner accepted the measured times.
  • The phone's link and steps are in the PR.
  • Shots without the switch are byte-equal to the merge base's.
  • The slider page holds every picture of the list, the implementer looked at each one, and the owner has it for checkpoint 7, with the question-4 views.

Out of scope

  • The plan layer, the readers under drawn_height, plain_height and stand_height, the mask reads, the probe of the eye, plants off water, the dither over the last FADE_M, the terrain in the fade band and the look of the handover to the far look: GP10.
  • The look on the mesh: smooth normals, the strip and terrain faces with the terrain's own normal and facet (a faint shading seam at the strip can show until then), markings in junctions, kerbs, walls, steep faces: GP11.
  • Deck plans, piers, abutments and portals (bridges near the eye show what structures/bridges.ts draws): GP12. Rails, sleepers and treads: GP13. Water beds and surfaces: GP14. Water crossings: GP15. Guardrails, posts and railings: GP16. Lamps and point objects: GP17.
  • The pieces, the strip and the floors themselves: GP7. The worker, the WASM bridge and the rest of Plan: GP5. The far look, its paint and its snap words: GP8.
  • Deleting debug=cracks, the corridors and the old stamp: GP19.
  • Heights and the leg rules: GP6. The leg split: GP3 follow-ups (TrailView ground plan GP3 follow-ups: open decisions and review items #3132).

Questions for the owner

  1. R = 0 (the lowest quality step): B at every distance is the near look; nothing else fits the budget. Recommendation: accept if the finest-16 shots show no stair, tooth or blob near the eye; else GP8 gets a near rule for B.
  2. Question 4 of the round: the near-mesh views of v17, i07 and d-hairpin with R and with W (legs split) are the views that the owner asked for. GP6 then deletes the other option.
  3. The stamp's p95: a stamp job with heights can spike on dense near pages. Recommendation: report the p95 shares; gate the medians only (owner: "about as fast as develop").

Decided (kept from the previous body)

Owner decision: every recommendation of the notes below is accepted.
Epic note: section 8 says a piece draws over "a node that draws at level 17 without a morph". GP9 reads this per point: the range keeps every drawn point of level 17 out of its morph. A test per node would take the pieces off most level-17 nodes past about 500 m.
Epic note: section 8 gives one range, but not what draws while a cell is not built or a pool is full. GP9 cuts the range to the nearest node that cannot draw, so the mesh always covers a full disk of nodes that can draw. GP10's readers then need only a distance test.
Epic note: section 8 says the floor is "one texture read per vertex". GP9 reads it from a storage buffer by vertex_index: one read per vertex, as the epic wants, but 65 × 65 grids do not tile texture layers.
Epic note: section 14, check 8 says "no terrain pixel inside the owned region". GP9 proves it within floorRange − TRIANGLE_M and proves no crack everywhere. In the last FADE_M the floor is off by the epic's section 8, so the terrain can show there; GP10 owns that band.

What changed from the previous body, and why

  • Past R_eff: GP8's far look of the records, not the previous far stamp. Reason: one road model.
  • The snap: on the new path past the floors, onto the model's core outline (owner-approved in TrailView ground plan GP8: the far stamp, one stamp function in every page on the new path, no snap #3102; interface comment 6639068685); no strip vertex snaps, by a stated bound.
  • The far look stops where pieces draw (paint_share with plan_range), and the lean terrain pipeline inside the floors' range, proven pixel-equal. Reason: the road terms cost every detail fragment.
  • R = 0: the worker builds only the records; B is the near look; the owner judges it.
  • Frame gate: the owner's whole-road rule against develop with the fair toggle and the clock rule, low tier first, in place of "the same sum with and without the switch".
  • Phone: GP9 owns the phone's memory and records run.
  • Kept: floors, pools, strip, range, R_eff, casters, hysteresis, debug=owned, the old path.

Generated by Claude Code

Activity

  1. timohueser commented on Nov 3, 2026

    @timohueser
    OwnerAuthor

    Deviation from GP0 (PR #3118), for this sub-issue's frame measurements: the viewer has no GPU timer of its own for corridors, band or water; all draw inside the one terrain pass, and the viewer has no switch for "ways off". GP0 measured ways-off with a scratch copy of flight.mjs that skips the corridors' draw and cast (see the baseline comment on #3096). The 0.8 ms gate compares the terrain + stamp passes with and without ways. Alternate the runs and correct for clock drift (the lighter frame drops the GPU clock by 2–7 %). If this sub-issue needs per-pass numbers for the new path, add its own timestamps or a switch.

  2. timohueser commented on Nov 3, 2026

    @timohueser
    OwnerAuthor

    Open owner question from GP0 (baseline comment on #3096): develop's water draw near the eye costs 0.125 ms (walk) and 0.073 ms (drive) on the GTX, measured with the alternate-frame toggle. The epic's frame budget charges ways, water and ownership on the new side but compares them only with develop's corridors, band and stamp (0.27 ms walk, 0.42 ms drive). Until the owner decides, report both: the reference without and with develop's water.

  3. timohueser commented on Nov 4, 2026

    @timohueser
    OwnerAuthor

    Interface change from GP8 (owner approved, see #3102):

    • Snap: the far stamp's snap runs on the new path, faded in by smoothstep(floor_range, plan_range, distance). GP9's vertex_main and piece_place pass the same snap flag, so terrain and strip vertices agree bit for bit. GP9 adds floor_range and plan_range to Surface and counts strip vertices with a snap word ≠ 0 (target 0). I6 now reads: "No vertex within floor_range moves. No strip vertex moves."
    • Corners: the far stamp gets FILLET records with GP3's corner radii and class priority in carriageway overlaps. Their cones can reach up to r (≤ 9 m) past an arm's own footprint, inside the corner's fan; GP6's owned region (I1) must cover this.
    • Paint: the across code is finer (an asinh scale) on the new path only.

    Generated by Claude Code

  4. timohueser commented on Nov 5, 2026

    @timohueser
    OwnerAuthor

    DRAFT for review: a new body of GP9 for the one road model. This comment does not change the issue body. An independent reviewer reads it before the owner does.

    Sources: the round's proposal (#3096 (comment), "GP9 changes in three parts"), its review (#3096 (comment)), the owner's decisions of 2026-11-05 (#3096 (comment)), the interface change from GP8 on this issue (#3103 (comment)), the GP7 and GP8 drafts, and on timosPC /media/timo/OSM1/tmp/ground-plan/one-model/ (shapes/budget/, shapes/bquality/, farb/, PROPOSAL-shapes.md section 7).

    How to read it. The floors, the pools, the strip draw, the range, R_eff and the crack count stay as approved. A section marked unchanged keeps the approved text word for word; the body edit copies it. The new text covers the three parts of the proposal (the far look past R_eff, the snap past the floors, the far look's stop where pieces draw), the near look at R = 0, and the frame gate.


    TrailView ground plan GP9: ownership in the viewer: pieces near, the far look of the records past them, floors, the strip, the snap, crack count

    Status: draft (one road model)
    Part of #3096. One pull request into ground-plan.
    Depends on: #3101 (GP7), #3102 (GP8, after its entry gate)

    TL;DR

    Goal: With ground=plan, the viewer draws GP7's pieces near the eye before the terrain, holds the terrain under them by its floors, and closes the mesh against the terrain at the strip, so that no crack shows anywhere and no terrain pixel shows inside the owned region within the floors' range, at level 18 and at level 17. Past R_eff the terrain shows GP8's far look of the same records. The snap moves terrain vertices onto the model's core outline only past the floors. The far look stops where pieces draw. A GPU count proves the ownership over the walk and drive flights, and the whole road share of a frame, pieces included, is no larger than develop's.

    Problem: GP7 builds pieces and floors, and GP8 paints and shapes every page from the records, but nothing draws the pieces. Without GP9, GP8's far look paints at every distance, its snap moves every terrain vertex near a road, including the vertices that the floors and the strip must keep on the grid, and the road terms of the look cost every detail fragment near the eye where the pieces draw the roads anyway.

    Decision: The approved ownership stays: the floor slot per node, plan/pieces.ts with its pools, R_eff, the strip through the terrain's own grid_vertex, @invariant clip positions, debug=owned. Three parts change: (1) past R_eff the terrain shows GP8's far look (B); (2) the snap factor is smoothstep(floor_range, plan_range, d), the same in vertex_main and piece_place, and no strip vertex has a snap word; (3) the far look's paint and ground terms stop where pieces draw, by the test of today's mask paint with surface.plan_range in place of the corridors' held, and terrain nodes wholly inside the floors' range draw with WAYS = false if the shots prove that nothing changes. At R = 0 no piece draws and B is the look at every distance. The frame gate is the owner's: no slower than develop, with the pieces and their casters, by GP0's alternate-frame method against the flight with no ways.

    Rejected: unchanged (the approved list), plus:

    Systems: trailview
    Contracts: none
    Size: L (as approved, plus the snap factor, the paint's stop and the lean terrain pipeline; about 800 to 1,100 changed lines)

    Facts

    Unchanged (approved Facts), plus:

    • GP8 writes the snap words from the model's core distance (paved area ∪ paved shoulder, shoulder, verge), and its far look (B) reads B's paint texture, the feature mask and the moved byte (GP8 draft, sections 1 and 2).
    • Today's paint draws where the corridors do not: paint_share is 1 past masks.held (terrain/features.wgsl:191-194).
    • The road terms of the look cost every detail fragment: B with no paint is 0.33 / 0.26 ms (walk / drive) cheaper than B (shapes/budget/, "(b) − ma32"). That is the most that a lean terrain pipeline inside the mesh range can save.
    • Develop's whole road share against the flight with no ways: 0.96 / 1.07 ms; its corridors 0.25 / 0.34 ms. B past 560 m, before pieces: 0.69 / 0.87 ms (shapes/budget/). The pieces replace the corridors.
    • B's quality nearer than 560 m is not measured since GP8's fix of the ground terms (farb/ item 1); before it, B near the eye showed the stair faults of the 8-bit mask (cost/page/page-vs-fragment.png).

    Design

    1. Constants

    Unchanged.

    2. What GP9 takes from GP5, GP7 and GP8

    Unchanged for GP5 and GP7. From GP8: under the switch, every page holds the model's heights, feature mask, moved byte and B's paint (and the far layers on the high tier); the corridors do not draw; the snap pass writes the snap words from the core distance. GP8's WAYS override and --toggle ways are GP9's test tools.

    3. The range R

    Unchanged.

    4. Cells

    Unchanged.

    5. Pools and floor slots

    Unchanged.

    6. The effective range R_eff

    Unchanged.

    7. Floors and the snap in the terrain

    Unchanged, with one change: vertex_main calls grid_vertex(u, level, snap) with snap = PLAN ? smoothstep(floor_range, plan_range, d) : 1, d the LOD distance of the vertex's grid point before the morph. With R = 0 (plan_range = floor_range = 0) the factor is 1 everywhere. So no vertex within floor_range moves, and the snap is full past R_eff.

    8. The pieces' draw

    Unchanged, with one change: piece_place passes the same snap factor to grid_vertex for a terrain vertex of the strip, so a strip vertex and the terrain's vertex at the same grid point stay bit-equal. The strip lies at least one level-17 cell outside every non-terrain face, past the core outline's snap reach, so its snap words are 0 by construction; the count of strip vertices with a snap word ≠ 0 proves it (target 0).

    9. Casters

    Unchanged.

    10. Lower quality and the hysteresis

    Unchanged, with one change for finest 16 (R = 0): no piece and no floor; B is the look at every distance (GP8), and the snap is full everywhere. B nearer than 560 m is the near look of that step. Its quality and cost are measured (Verification) and the owner accepts it or asks for more.

    11. What GP9 gives the other items

    Unchanged, plus: the snap factor; surface.plan_range as the far look's stop.

    12. The ownership count: debug=owned

    Unchanged.

    13. Edge cases

    Unchanged, with these lines changed:

      1. Node that draws a coarser page: drawable is false, so R_eff stops before it, and the far look and the snap act there (they read the coarser page's mask).
      1. Without the switch: section 14.
    • New: a node that crosses R_eff: its terrain draws the far look per pixel past plan_range (section 15) and nothing nearer.
    • New: a page whose blocks of the model's records have not arrived (GP8 section 3; not GP5's cell records): the far look shows no way there until the blocks arrive and the page is stamped again. Inside R_eff every cell is built, so its block exists.

    14. The old path

    Unchanged, plus: WAYS and the snap factor fold away without the switch (the factor is 1).

    15. The far look stops where pieces draw (new)

    • Paint and ground terms: on the new path, paint_share reads surface.plan_range in place of masks.held: B paints and shapes the look only where a pixel's LOD distance is at least plan_range. That is the test of today's mask paint, with the pieces in the corridors' place. GP10 replaces the hard edge with the dither of its fade band.
    • The lean terrain pipeline: a terrain node whose far LOD distance is under floor_range draws with WAYS = false. Its pixels lie under pieces, or on plain terrain outside the owned region, where no way has a road term (outside every non-terrain face the height is the plain terrain, and GP7's owned region holds every slope band). Proof: shots of every view of Pictures with and without the lean pipeline differ in no pixel. If a pixel differs, a way is missing from the mesh: the PR names it, and the lean pipeline stays off until it is fixed. Expected saving: at most 0.33 / 0.26 ms.

    Plan

    Unchanged (steps 1 to 16), plus:

    • Step 10 (terrain/terrain.wgsl): the snap factor in vertex_main; paint_share with surface.plan_range on the new path.
    • Step 11 (terrain/terrain.ts): the lean pipeline (WAYS = false) for nodes inside floor_range, cached per switch value.
    • Step 5 (plan/piece.wgsl): the snap factor in piece_place; the count of strip vertices with a snap word.
    • Step 13: flight.mjs --toggle ways skips the pieces and their casters on odd frames (GP8's toggle).

    Files

    Unchanged, plus ground/look.wgsl (the WAYS pipeline is GP8's; GP9 uses it) and terrain/features.wgsl (paint_share).

    Verification

    • Checks: unchanged (vitest, npm run check, npm run check:wgsl, the @gpu test, obc test affected, obc prose --check), plus the count of strip vertices with a snap word ≠ 0 (0 in every view and flight frame) and the lean pipeline's pixel test (section 15).
    • Frame gate (replaces the approved gate): flight.mjs --gpu --toggle ways, walk and valley-drive, 2560 × 1600, GTX 1660 SUPER, two runs each, one session, flock /tmp/trailview-gpu.lock, the clock printed per run. Runs: develop (the old path of the same build, which is pixel-equal to develop), and &ground=plan on the high and the low tier. The road share = terrain (on − off) + shadow and far-shadow (on − off) + the stamp per frame. Gate: on both flights and both tiers, the new path's road share is at most develop's, within the run-to-run spread (owner: "about as fast as develop, ideally a little faster"). Report the parts: pieces, their casters, B past R_eff, the stamp, and the saving of the lean pipeline. Also report the p95 shares and the stamp's p95 per frame (a stamp job with heights can spike by up to 1.7 ms on dense near pages).
    • R = 0: the same toggle with &ground=plan&finest=16, against develop at finest=16. Gate: the same rule. Pictures below.
    • Measurements: unchanged (discard cost, old path pixel-equal, the count with --owned 10 at finest 18 and 17, pools, range, jump), without the approved frame gate.
    • Pictures: unchanged (the views at eye height and from above, debug=owned, finest 17 and 16, the end of the range), plus:
      • main-road-15m, valley-eye, t-tertiary-eye: the snap acts only past the floors; no wobble of the far edge lines, and no seam where it starts;
      • finest 16 (R = 0) at v06-road-path-broken, v12-good-town-junctions, v17-sharp-corners, i07-hairpin-eye, main-road-15m and their -top views: B as the near look. The owner judges whether B near the eye is good enough for the lowest quality step;
      • v17, i07 and d-hairpin on the near mesh with GP6's heights.legs = ramp and = wall (W with GP3's leg split): the views for the owner's question 4.

    Done when

    • The pull request into ground-plan has merged with green CI and resolved review findings.
    • The checks pass, the @gpu test included (local run reported).
    • cracks = 0 and inside = 0 in the walk and valley-drive flights at level 18 and at level 17, and in every view of the picture list; no strip vertex has a snap word.
    • The frame gate holds on both flights and both tiers, or the owner decides on the measured numbers; the R = 0 gate likewise.
    • The pool gate and the walk's range gate hold; the largest plan.ready is at most 2,000 ms, or the owner accepted the measured times.
    • Shots without the switch are byte-equal to the merge base's.
    • The slider page holds every picture of the list, the implementer looked at each one, and the owner has it for checkpoint 7, with the question-4 views.

    Out of scope

    Unchanged, with: the far look itself and its snap words: GP8; heights and the leg rules: GP6.

    Questions for the owner

    1. R = 0 (the lowest quality step): B at every distance is the near look; nothing else fits the budget. Recommendation: accept if the finest-16 shots show no stair, tooth or blob near the eye; else GP8 gets a near rule for B.
    2. Question 4 of the round: the near-mesh views of v17, i07 and d-hairpin with R and with W (legs split) are the views that the owner asked for. GP6 then deletes the other option.

    What changed from the approved body, and why

    • Past R_eff: GP8's far look of the records, not the approved far stamp. Reason: one road model.
    • The snap: on the new path past the floors, onto the model's core outline (owner-approved in TrailView ground plan GP8: the far stamp, one stamp function in every page on the new path, no snap #3102; interface comment 6639068685); no strip vertex snaps.
    • The far look stops where pieces draw (paint_share with plan_range), and the lean terrain pipeline inside the floors' range, proven pixel-equal. Reason: the road terms cost every detail fragment.
    • R = 0: B is the near look; the owner judges it.
    • Frame gate: the owner's whole-road rule against develop with the fair toggle, in place of "the same sum with and without the switch".
    • Kept: floors, pools, strip, range, R_eff, casters, hysteresis, debug=owned, the old path.

    Generated by Claude Code

  5. timohueser commented on Nov 6, 2026

    @timohueser
    OwnerAuthor

    Deviations of the live viewer (branch claude/gp9-live, draft PR into ground-plan) from this body. The orchestrator asked for a live viewer on GP7-C's 2D mesh and GP8's far look within a day; the body above predates both. These are the differences.

    1. No floors, no floor slots. The terrain sinks its level-18 vertices by GP7-C's R bits (a grid point whose region cells all lie in R), 200 m down, in a window of 7 × 7 cells with 24 slots (plan/cells.wgsl). A vertex sinks only where a mesh draws its cell, on the level-18 plateau of the LOD, and where every cell that holds the point has a mesh.
    2. No R_eff, candidates or pools. A cell's mesh draws when the cell holds a level-18 node and its corners hold their heights. Over the morph band of level 18 (the plateau's end to level 17) the mesh fades out by a dither, and the terrain there neither sinks nor loses its snap; past it, B draws. So GP10's fade band is in place, without GP10's readers.
    3. The snap: a level-18 vertex takes no snap of its own on the plateau of a meshed cell; the snap of the coarser level that it morphs toward stays on. Terrain and strip call the same grid_vertex_by, so the strip's terrain vertices stay bit-equal (@invariant on both).
    4. The corners: a compute pass makes each corner's height (height_at, the plain terrain from the stored pages; the terrain rule takes the drawn page), the paved faces' normals (central differences of the function) and the look words, at most 8,192 corners a frame. Ground faces of the mesh take the pages' normal and B's ground terms per pixel, so they shade as the terrain beside them.
    5. One build per cell: GP8's records store builds every cell in two plan workers; the same build gives the records, the rim and the mesh input. The store asks for a mesh once the eight neighbours have rims or are empty. GP5's Plan builds only for plan=faces. GP7-C's worker-side rim wait is not used.
    6. Casters: the meshes cast nothing yet.
    7. Not done: debug=owned with its backstop and green count. In its place, debug=cracks with ground=plan draws the meshes grey and the sunk terrain red: a red pixel is a crack or a hole in a mesh. The far look's paint still runs under the meshes (no plan_range stop, no lean pipeline).

    Generated by Claude Code

  6. timohueser commented on Nov 6, 2026

    @timohueser
    OwnerAuthor

    GP9 landed in ground-plan with PR #3164 (merge cacad6d), after the review, the fixes and the delta review. The live viewer draws the GP7-C pieces near the eye and GP8's far look B past them, on one model. Frame against develop: +0.2 ms walk, +0.36 ms drive (mean). Corner pass worst frame 3.3 ms. A full mesh is ready in 2.4–2.8 s. The remaining look faults are in the follow-up issue.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions