@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 +21 -0
- package/README.md +78 -0
- package/package.json +54 -0
- package/src/index.ts +235 -0
- package/src/shader.ts +212 -0
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
|
+
`;
|