@dylanebert/shallot-grid 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dylan Ebert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # shallot-grid
2
+
3
+ An infinite world grid with axis lines for [Shallot](https://github.com/dylanebert/shallot). One fullscreen pass draws the y=0 plane. Each pixel picks two decade levels from its own screen footprint and cross-fades between them, so the grid reads at any zoom without popping. The horizon fade scales with camera height, and red X, green Y and blue Z axis lines draw in the same pass.
4
+
5
+ ## Enabling it
6
+
7
+ ```bash
8
+ bun add @dylanebert/shallot-grid
9
+ ```
10
+
11
+ Name it in `shallot.json`:
12
+
13
+ ```json
14
+ {
15
+ "scene": "scenes/main.scene",
16
+ "plugins": {
17
+ "Grid": "@dylanebert/shallot-grid"
18
+ }
19
+ }
20
+ ```
21
+
22
+ Add a `<a grid />` singleton to your scene to show it:
23
+
24
+ ```
25
+ <a grid />
26
+ ```
27
+
28
+ Every field is optional. Colors are sRGB hex with alpha (`0xRRGGBBAA`), and an axis with alpha `00` is hidden:
29
+
30
+ ```
31
+ <a grid="neutral: 0x504945ff; axis-x: 0xcc241dff; axis-y: 0x6b9d65ff; axis-z: 0x458588ff; opacity: 1; fade: 20; cells: 10" />
32
+ ```
33
+
34
+ - `opacity`: the whole grid's opacity.
35
+ - `fade`: horizon fade reach in camera heights; `0` turns it off.
36
+ - `cells`: pixels per cell at a decade boundary; the finest level's cells span `cells / 10` to `cells` pixels.
37
+
38
+ ## Layout
39
+
40
+ - `src/`: the plugin (`index.ts`) and its shader (`shader.ts`)
41
+ - `examples/world-grid`: I want an infinite reference grid with world axes under my scene. Run it with `bunx shallot dev examples/world-grid`.
42
+ - `tests/frame-cost.oracle.ts`: the `shallot-grid-frame-cost` oracle, which profiles the example at 1920×1080 in headed Chromium and refuses a grid pass over 0.5 ms of GPU time
43
+
44
+ ## Developing
45
+
46
+ ```bash
47
+ bun install --frozen-lockfile
48
+ bun run list # installed Shallot carrier population
49
+ bun run workflow # regenerate the hosted surface workflow
50
+ bun run check # tsc + Biome + carrier declaration/drift checks
51
+ bun run test # installed carrier unit sweep (src/)
52
+ bun test ./tests/frame-cost.oracle.ts # grid pass GPU cost at 1080p (headed Chromium, host GPU)
53
+ ```
54
+
55
+ ### Against a local engine
56
+
57
+ `@dylanebert/shallot` is a peer dependency on the published range `>=0.9.5`; the dev dependency is pinned to the exact Git commit. To test against an unreleased engine, link it and its `typegpu` too:
58
+
59
+ ```bash
60
+ # in your shallot checkout
61
+ bun link
62
+ cd node_modules/typegpu && bun link
63
+
64
+ # here
65
+ bun link @dylanebert/shallot
66
+ bun link typegpu
67
+ bun test src
68
+ ```
69
+
70
+ Run `bun install --frozen-lockfile --force` here to replace the local links with the exact Shallot dependency recorded in `bun.lock`.
71
+
72
+ ## Releasing
73
+
74
+ Bump `version` in `package.json`, commit, and push a matching `v<version>` tag. The release workflow runs the native `bun run check` and `bun run test` gates before publishing.
75
+
76
+ ## License
77
+
78
+ MIT
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@dylanebert/shallot-grid",
3
+ "version": "0.1.0",
4
+ "description": "Infinite world grid with axis lines for Shallot: a fullscreen ray-plane pass that blends decade levels so it reads at any zoom.",
5
+ "license": "MIT",
6
+ "author": "Dylan Ebert <dylan.ebert@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dylanebert/shallot-grid.git"
10
+ },
11
+ "homepage": "https://github.com/dylanebert/shallot-grid",
12
+ "bugs": "https://github.com/dylanebert/shallot-grid/issues",
13
+ "keywords": [
14
+ "webgpu",
15
+ "grid",
16
+ "shallot"
17
+ ],
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "packageManager": "bun@1.4.2",
21
+ "engines": {
22
+ "bun": ">=1.4.2"
23
+ },
24
+ "exports": {
25
+ ".": "./src/index.ts"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "!src/**/*.test.ts"
30
+ ],
31
+ "scripts": {
32
+ "check": "tsc --noEmit && biome check && shallot check",
33
+ "list": "shallot list",
34
+ "workflow": "shallot workflow",
35
+ "test": "shallot test"
36
+ },
37
+ "peerDependencies": {
38
+ "@dylanebert/shallot": ">=0.9.5",
39
+ "typegpu": "~0.12.5"
40
+ },
41
+ "devDependencies": {
42
+ "@biomejs/biome": "^2.5.13",
43
+ "@dylanebert/shallot": "github:dylanebert/shallot#45a1e7bbcfd9bc032082019a573eddeda496d496",
44
+ "@types/bun": "latest",
45
+ "@webgpu/types": "^0.1.72",
46
+ "playwright": "^1.63.0",
47
+ "typegpu": "~0.12.5",
48
+ "typescript": "^7.0.2",
49
+ "unplugin-typegpu": "~0.12.3"
50
+ },
51
+ "workspaces": [
52
+ "examples/*"
53
+ ]
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,235 @@
1
+ // Grid: an infinite world grid on the y=0 plane with X, Y and Z axis lines, drawn as one fullscreen
2
+ // ray-plane pass after sear's color pass and before glaze. The mechanism is in `./shader`. One `Grid`
3
+ // singleton holds the look; the scene tag `<a grid />` is the whole happy path.
4
+ import type { Plugin, State, System } from "@dylanebert/shallot";
5
+ import {
6
+ Camera,
7
+ Compute,
8
+ f32,
9
+ formatHex,
10
+ invert,
11
+ sparse,
12
+ Transform,
13
+ u32,
14
+ unpackColor,
15
+ } from "@dylanebert/shallot";
16
+ import { GlazePlugin, GlazeSystem } from "@dylanebert/shallot/glaze";
17
+ import {
18
+ computeViewProj,
19
+ Render,
20
+ RenderPlugin,
21
+ type View,
22
+ Views,
23
+ } from "@dylanebert/shallot/render";
24
+ import { ColorSystem, DEPTH_FORMAT, Depth, SearPlugin } from "@dylanebert/shallot/sear";
25
+ import { GRID_AT, GRID_BYTES, GRID_FLOATS, GRID_SHADER } from "./shader";
26
+
27
+ /**
28
+ * the scene's world grid, one per scene (a singleton). Colors are hex sRGB bytes with alpha
29
+ * (0xRRGGBBAA); an axis with alpha 0 is hidden.
30
+ *
31
+ * @example
32
+ * ```
33
+ * <a grid="axis-y: 0x6b9d6500; fade: 40; cells: 12" />
34
+ * ```
35
+ */
36
+ export const Grid = {
37
+ /** hex sRGB color with alpha of the neutral grid lines (e.g. 0x504945ff) */
38
+ neutral: sparse(u32),
39
+ /** hex sRGB color with alpha of the X axis; alpha 0 hides it */
40
+ axisX: sparse(u32),
41
+ /** hex sRGB color with alpha of the vertical Y axis; alpha 0 hides it */
42
+ axisY: sparse(u32),
43
+ /** hex sRGB color with alpha of the Z axis; alpha 0 hides it */
44
+ axisZ: sparse(u32),
45
+ /** opacity of the whole grid [0,1] */
46
+ opacity: sparse(f32),
47
+ /** horizon fade reach in camera heights: the plane fades out by `fade × height` (0 = off) */
48
+ fade: sparse(f32),
49
+ /** pixels per cell at a decade boundary: the finest level's cells span k/10 to k pixels */
50
+ cells: sparse(f32),
51
+ };
52
+
53
+ /** the look a `Grid` singleton carries, one number per field. */
54
+ export type GridStyle = { [K in keyof typeof Grid]: number };
55
+
56
+ export const GRID_DEFAULTS: GridStyle = {
57
+ neutral: 0x504945ff,
58
+ axisX: 0xcc241dff,
59
+ axisY: 0x6b9d65ff,
60
+ axisZ: 0x458588ff,
61
+ opacity: 1,
62
+ fade: 20,
63
+ cells: 10,
64
+ };
65
+
66
+ function packColorAt(rgba: number, out: Float32Array, at: number): void {
67
+ const { r, g, b } = unpackColor(rgba >>> 8);
68
+ out[at] = r;
69
+ out[at + 1] = g;
70
+ out[at + 2] = b;
71
+ out[at + 3] = (rgba & 0xff) / 255;
72
+ }
73
+
74
+ /** pack a grid look into its `Grid` uniform lanes. Colors decode to linear rgb with a 0..1 alpha. */
75
+ export function packGrid(style: GridStyle, out: Float32Array): void {
76
+ packColorAt(style.neutral, out, GRID_AT.neutral);
77
+ packColorAt(style.axisX, out, GRID_AT.axisX);
78
+ packColorAt(style.axisY, out, GRID_AT.axisY);
79
+ packColorAt(style.axisZ, out, GRID_AT.axisZ);
80
+ out[GRID_AT.params] = style.opacity;
81
+ out[GRID_AT.params + 1] = style.fade;
82
+ out[GRID_AT.params + 2] = style.cells;
83
+ out[GRID_AT.params + 3] = 0;
84
+ }
85
+
86
+ function readGrid(eid: number): GridStyle {
87
+ return {
88
+ neutral: Grid.neutral.get(eid),
89
+ axisX: Grid.axisX.get(eid),
90
+ axisY: Grid.axisY.get(eid),
91
+ axisZ: Grid.axisZ.get(eid),
92
+ opacity: Grid.opacity.get(eid),
93
+ fade: Grid.fade.get(eid),
94
+ cells: Grid.cells.get(eid),
95
+ };
96
+ }
97
+
98
+ const ALPHA_BLEND: GPUBlendState = {
99
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha" },
100
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" },
101
+ };
102
+
103
+ const gpu = {
104
+ pipeline: null as GPURenderPipeline | null,
105
+ uniform: null as GPUBuffer | null,
106
+ bindGroup: null as GPUBindGroup | null,
107
+ };
108
+
109
+ const _data = new Float32Array(GRID_FLOATS);
110
+ const _viewProj = new Float32Array(16);
111
+ const _invViewProj = new Float32Array(16);
112
+
113
+ function drawGrid(camera: number, view: View): void {
114
+ const device = Compute.device;
115
+ const encoder = Render.encoder;
116
+ if (!device || !encoder || !gpu.pipeline || !gpu.uniform || !gpu.bindGroup) return;
117
+ if (!view.framebuffer || !view.depth || view.width === 0 || view.height === 0) return;
118
+
119
+ computeViewProj(camera, view.width / view.height, _viewProj);
120
+ invert(_viewProj, _invViewProj);
121
+ _data.set(_viewProj, GRID_AT.viewProj);
122
+ _data.set(_invViewProj, GRID_AT.invViewProj);
123
+ _data[GRID_AT.camPos] = Transform.pos.x.get(camera);
124
+ _data[GRID_AT.camPos + 1] = Transform.pos.y.get(camera);
125
+ _data[GRID_AT.camPos + 2] = Transform.pos.z.get(camera);
126
+ _data[GRID_AT.camPos + 3] = 1;
127
+ device.queue.writeBuffer(gpu.uniform, 0, _data);
128
+
129
+ const pass = encoder.beginRenderPass({
130
+ label: "grid",
131
+ colorAttachments: [{ view: view.framebuffer, loadOp: "load", storeOp: "store" }],
132
+ depthStencilAttachment: { view: view.depth, depthLoadOp: "load", depthStoreOp: "store" },
133
+ timestampWrites: Compute.span?.("grid"),
134
+ });
135
+ pass.setPipeline(gpu.pipeline);
136
+ pass.setBindGroup(0, gpu.bindGroup);
137
+ pass.draw(6);
138
+ pass.end();
139
+ }
140
+
141
+ // draws the grid into every camera's view after sear's color pass and before glaze composites it, marking
142
+ // each camera `Depth` so sear stores the depth the pass tests against. No-op unless the scene has a Grid
143
+ // singleton.
144
+ const GridSystem: System = {
145
+ name: "grid",
146
+ group: "draw",
147
+ after: [ColorSystem],
148
+ before: [GlazeSystem],
149
+ update(state: State) {
150
+ const eid = state.only([Grid]);
151
+ if (eid < 0) return;
152
+ packGrid(readGrid(eid), _data);
153
+ for (const camera of state.query([Camera])) {
154
+ // the pass depth-tests against the scene, which sear publishes only for a `Depth` camera
155
+ if (!state.has(camera, Depth)) state.add(camera, Depth);
156
+ const view = Views.get(camera);
157
+ if (view) drawGrid(camera, view);
158
+ }
159
+ },
160
+ };
161
+
162
+ /**
163
+ * infinite world grid with X, Y and Z axes. Opt-in: add `GridPlugin` to the plugin set and give the scene
164
+ * one {@link Grid} singleton (`<a grid />`).
165
+ */
166
+ export const GridPlugin: Plugin = {
167
+ name: "Grid",
168
+ components: { Grid },
169
+ traits: {
170
+ Grid: {
171
+ singleton: true,
172
+ defaults: () => ({ ...GRID_DEFAULTS }),
173
+ format: {
174
+ neutral: formatHex,
175
+ axisX: formatHex,
176
+ axisY: formatHex,
177
+ axisZ: formatHex,
178
+ },
179
+ },
180
+ },
181
+ systems: [GridSystem],
182
+ dependencies: [RenderPlugin, SearPlugin, GlazePlugin],
183
+
184
+ async warm() {
185
+ const device = Compute.device;
186
+ if (!device) return;
187
+ const module = device.createShaderModule({ label: "grid", code: GRID_SHADER });
188
+ const layout = device.createBindGroupLayout({
189
+ label: "grid",
190
+ entries: [
191
+ {
192
+ binding: 0,
193
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
194
+ buffer: { type: "uniform" },
195
+ },
196
+ ],
197
+ });
198
+ gpu.uniform?.destroy();
199
+ gpu.uniform = device.createBuffer({
200
+ label: "grid-uniform",
201
+ size: GRID_BYTES,
202
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
203
+ });
204
+ gpu.bindGroup = device.createBindGroup({
205
+ label: "grid",
206
+ layout,
207
+ entries: [{ binding: 0, resource: { buffer: gpu.uniform } }],
208
+ });
209
+ gpu.pipeline = await device.createRenderPipelineAsync({
210
+ label: "grid",
211
+ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
212
+ vertex: { module, entryPoint: "vs" },
213
+ fragment: {
214
+ module,
215
+ entryPoint: "fs",
216
+ targets: [{ format: Render.format, blend: ALPHA_BLEND }],
217
+ },
218
+ depthStencil: {
219
+ format: DEPTH_FORMAT,
220
+ depthCompare: "greater-equal",
221
+ depthWriteEnabled: false,
222
+ },
223
+ primitive: { topology: "triangle-list" },
224
+ });
225
+ },
226
+
227
+ dispose() {
228
+ gpu.uniform?.destroy();
229
+ gpu.uniform = null;
230
+ gpu.bindGroup = null;
231
+ gpu.pipeline = null;
232
+ },
233
+ };
234
+
235
+ export default GridPlugin;
package/src/shader.ts ADDED
@@ -0,0 +1,212 @@
1
+ // The world grid shader: one fullscreen ray-plane pass over the y=0 plane plus the vertical Y axis.
2
+ //
3
+ // Decade blend. The plane point's screen footprint is `f = max(fwidth(p.x), fwidth(p.z))`, metres per
4
+ // pixel. With the cells constant `k`, `l = log10(f * k)` names the decade: the finer level's cell is
5
+ // `10^floor(l)` metres and spans between k/10 and k pixels, the coarser level's is `10^(floor(l)+1)`
6
+ // and spans between k and 10k pixels. Both draw with Ben Golus's pristine line primitive (PlayCanvas
7
+ // `scripts/esm/grid.mjs`, `pristineGrid`) at a constant screen width, so a level whose cells approach
8
+ // pixel size resolves to its mean coverage instead of moiré. The finer level is weighted
9
+ // `1 - fract(l)` and the coarser level at full weight, combined with `max`. Every coarse line is also
10
+ // a fine line, so as `fract(l)` reaches 1 the fine level has faded to nothing and the coarse level
11
+ // alone remains, and at the next decade that coarse level becomes the fine level at weight 1. Nothing
12
+ // pops, and no zoom runs out of levels.
13
+ //
14
+ // View-relative fade. The footprint already fades the fine level. The plane also fades toward the
15
+ // horizon by `1 - smoothstep(0, fade * h, dist)`, where `h` is the camera's height above the plane and
16
+ // `dist` the camera's distance to the plane point. Both scale together with zoom, so the grid looks
17
+ // the same at 0.5 m and at 5000 m; `fade: 0` turns the horizon fade off.
18
+ //
19
+ // Y axis. The line x=z=0 is not on the plane, so the fragment finds the ray's closest approach to it,
20
+ // draws it at the plane axes' screen width from the derivative of its signed lateral distance, and
21
+ // takes the nearer of plane and axis depth. It is the one element that draws when the ray misses the
22
+ // plane.
23
+ //
24
+ // Depth is reverse-Z (near 1, far 0): the pass tests `greater-equal` with depth writes off and writes
25
+ // the winning element's depth through `frag_depth`.
26
+
27
+ /** f32 lane offsets of the `Grid` uniform, as the WGSL struct below lays it out. */
28
+ export const GRID_AT = {
29
+ viewProj: 0,
30
+ invViewProj: 16,
31
+ camPos: 32,
32
+ neutral: 36,
33
+ axisX: 40,
34
+ axisY: 44,
35
+ axisZ: 48,
36
+ params: 52,
37
+ } as const;
38
+
39
+ /** byte size of the `Grid` uniform. */
40
+ export const GRID_BYTES = 224;
41
+ /** `Grid` uniform size in f32 lanes. */
42
+ export const GRID_FLOATS = GRID_BYTES / 4;
43
+
44
+ export const GRID_SHADER = /* wgsl */ `
45
+ struct Grid {
46
+ viewProj: mat4x4<f32>,
47
+ invViewProj: mat4x4<f32>,
48
+ camPos: vec4<f32>,
49
+ neutral: vec4<f32>,
50
+ axisX: vec4<f32>,
51
+ axisY: vec4<f32>,
52
+ axisZ: vec4<f32>,
53
+ params: vec4<f32>,
54
+ }
55
+
56
+ @group(0) @binding(0) var<uniform> grid: Grid;
57
+
58
+ const LINE_PX: f32 = 1.0;
59
+ const AXIS_PX: f32 = 2.0;
60
+ const INV_LN10: f32 = 0.4342944819;
61
+
62
+ struct VSOut {
63
+ @builtin(position) position: vec4<f32>,
64
+ @location(0) nearPoint: vec3<f32>,
65
+ @location(1) farPoint: vec3<f32>,
66
+ }
67
+
68
+ fn unproject(p: vec3<f32>) -> vec3<f32> {
69
+ let u = grid.invViewProj * vec4(p, 1.0);
70
+ return u.xyz / u.w;
71
+ }
72
+
73
+ @vertex
74
+ fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
75
+ let pos = array<vec2<f32>, 6>(
76
+ vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(-1.0, 1.0),
77
+ vec2(-1.0, 1.0), vec2(1.0, -1.0), vec2(1.0, 1.0),
78
+ );
79
+ let p = pos[vi];
80
+ var out: VSOut;
81
+ out.position = vec4(p, 0.0, 1.0);
82
+ // reverse-Z: clip z 1 is the near plane, 0 the far plane
83
+ out.nearPoint = unproject(vec3(p, 1.0));
84
+ out.farPoint = unproject(vec3(p, 0.0));
85
+ return out;
86
+ }
87
+
88
+ struct FragOut {
89
+ @builtin(frag_depth) depth: f32,
90
+ @location(0) color: vec4<f32>,
91
+ }
92
+
93
+ // Ben Golus's pristine grid for thin lines: uv in cells, deriv the per-axis cell footprint of one pixel,
94
+ // width the line width in cells. Coverage fades to the line's mean coverage as cells shrink under a pixel.
95
+ fn pristine(uv: vec2<f32>, deriv: vec2<f32>, width: vec2<f32>) -> f32 {
96
+ let goal = min(width, vec2(0.5));
97
+ let drawWidth = clamp(goal, deriv, vec2(0.5));
98
+ let aa = deriv * 1.5;
99
+ let g = 1.0 - abs(fract(uv) * 2.0 - 1.0);
100
+ var g2 = 1.0 - smoothstep(drawWidth - aa, drawWidth + aa, g);
101
+ g2 = g2 * saturate(goal / drawWidth);
102
+ g2 = mix(g2, goal, saturate(deriv * 2.0 - 1.0));
103
+ return mix(g2.x, 1.0, g2.y);
104
+ }
105
+
106
+ fn axisCoverage(px: f32) -> f32 {
107
+ return saturate(AXIS_PX * 0.5 + 0.5 - px);
108
+ }
109
+
110
+ fn depthOf(p: vec3<f32>) -> f32 {
111
+ let clip = grid.viewProj * vec4(p, 1.0);
112
+ if (clip.w <= 0.0) { return -1.0; }
113
+ return clip.z / clip.w;
114
+ }
115
+
116
+ fn over(front: vec4<f32>, back: vec4<f32>) -> vec4<f32> {
117
+ let a = front.a + back.a * (1.0 - front.a);
118
+ if (a <= 0.0) { return vec4(0.0); }
119
+ let rgb = (front.rgb * front.a + back.rgb * back.a * (1.0 - front.a)) / a;
120
+ return vec4(rgb, a);
121
+ }
122
+
123
+ @fragment
124
+ fn fs(input: VSOut) -> FragOut {
125
+ let opacity = grid.params.x;
126
+ let fade = grid.params.y;
127
+ let cells = max(grid.params.z, 1e-3);
128
+
129
+ let origin = input.nearPoint;
130
+ let ray = input.farPoint - input.nearPoint;
131
+
132
+ // plane point; derivatives stay in uniform control flow
133
+ let t = -origin.y / ray.y;
134
+ let p = origin + t * ray;
135
+ let dx = dpdx(p.xz);
136
+ let dy = dpdy(p.xz);
137
+ let deriv = vec2(length(vec2(dx.x, dy.x)), length(vec2(dx.y, dy.y)));
138
+ let f = max(fwidth(p.x), fwidth(p.z));
139
+
140
+ // Y axis: closest approach of the ray to x=z=0, measured in the xz plane
141
+ let rayXZ = ray.xz;
142
+ let flatLen2 = dot(rayXZ, rayXZ);
143
+ let s = select(0.0, max(-dot(origin.xz, rayXZ) / max(flatLen2, 1e-12), 0.0), flatLen2 > 1e-12);
144
+ let q = origin + s * ray;
145
+ let side = vec2(-rayXZ.y, rayXZ.x) / sqrt(max(flatLen2, 1e-24));
146
+ let lateral = dot(origin.xz, side);
147
+ let lateralPx = abs(lateral) / max(fwidth(lateral), 1e-12);
148
+
149
+ var plane = vec4(0.0);
150
+ var planeDepth = -1.0;
151
+ let hit = t > 0.0 && abs(ray.y) > 1e-12;
152
+ if (hit) {
153
+ planeDepth = depthOf(p);
154
+ }
155
+ if (hit && planeDepth >= 0.0 && planeDepth <= 1.0) {
156
+ let l = log(f * cells) * INV_LN10;
157
+ let fine = pow(10.0, floor(l));
158
+ let coarse = fine * 10.0;
159
+ let fineWeight = 1.0 - fract(l);
160
+ let gFine = pristine(p.xz / fine, deriv / fine, deriv * LINE_PX / fine);
161
+ let gCoarse = pristine(p.xz / coarse, deriv / coarse, deriv * LINE_PX / coarse);
162
+ var alpha = max(gCoarse, gFine * fineWeight) * grid.neutral.a;
163
+ var color = grid.neutral.rgb;
164
+
165
+ let xCov = axisCoverage(abs(p.z) / max(deriv.y, 1e-12)) * grid.axisX.a;
166
+ let zCov = axisCoverage(abs(p.x) / max(deriv.x, 1e-12)) * grid.axisZ.a;
167
+ color = mix(color, grid.axisX.rgb, xCov);
168
+ alpha = max(alpha, xCov);
169
+ color = mix(color, grid.axisZ.rgb, zCov * (1.0 - xCov));
170
+ alpha = max(alpha, zCov);
171
+
172
+ if (fade > 0.0) {
173
+ let h = abs(grid.camPos.y);
174
+ let dist = length(p - grid.camPos.xyz);
175
+ alpha = alpha * (1.0 - smoothstep(0.0, fade * h, dist));
176
+ }
177
+ plane = vec4(color, alpha);
178
+ } else {
179
+ planeDepth = -1.0;
180
+ }
181
+
182
+ var axis = vec4(0.0);
183
+ var axisDepth = -1.0;
184
+ if (flatLen2 > 1e-12) {
185
+ axisDepth = depthOf(vec3(0.0, q.y, 0.0));
186
+ if (axisDepth >= 0.0 && axisDepth <= 1.0) {
187
+ axis = vec4(grid.axisY.rgb, axisCoverage(lateralPx) * grid.axisY.a);
188
+ } else {
189
+ axisDepth = -1.0;
190
+ }
191
+ }
192
+
193
+ var color: vec4<f32>;
194
+ var depth: f32;
195
+ if (axis.a > 0.0 && axisDepth >= planeDepth) {
196
+ color = over(axis, plane);
197
+ depth = axisDepth;
198
+ } else if (plane.a > 0.0) {
199
+ color = over(plane, axis);
200
+ depth = planeDepth;
201
+ } else {
202
+ discard;
203
+ }
204
+ color.a = color.a * opacity;
205
+ if (color.a < 1.0 / 255.0) { discard; }
206
+
207
+ var out: FragOut;
208
+ out.depth = depth;
209
+ out.color = color;
210
+ return out;
211
+ }
212
+ `;