vgpu-render 0.0.0-stage → 0.2.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 +605 -2
- package/dist/assets/fira-mono-medium-subset.d.ts +4 -0
- package/dist/assets/fira-mono-medium-subset.js +889 -0
- package/dist/assets/fira-mono-msdf-atlas.d.ts +13 -0
- package/dist/assets/fira-mono-msdf-atlas.js +6222 -0
- package/dist/camera-2d.d.ts +43 -0
- package/dist/camera-2d.js +75 -0
- package/dist/color-space.d.ts +32 -0
- package/dist/color-space.js +45 -0
- package/dist/curve.d.ts +16 -0
- package/dist/curve.js +46 -0
- package/dist/debug-draw-3d.d.ts +14 -0
- package/dist/debug-draw-3d.js +36 -0
- package/dist/frame-draw-list-2d.d.ts +177 -0
- package/dist/frame-draw-list-2d.js +378 -0
- package/dist/frame-draw-list.d.ts +413 -0
- package/dist/frame-draw-list.js +556 -0
- package/dist/geometry-generators.d.ts +101 -0
- package/dist/geometry-generators.js +481 -0
- package/dist/gpu-debug-draw-2d.d.ts +48 -0
- package/dist/gpu-debug-draw-2d.js +450 -0
- package/dist/gpu-debug-draw-3d.d.ts +52 -0
- package/dist/gpu-debug-draw-3d.js +892 -0
- package/dist/gpu-instancing.d.ts +34 -0
- package/dist/gpu-instancing.js +93 -0
- package/dist/gpu-text-3d.d.ts +98 -0
- package/dist/gpu-text-3d.js +514 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +20 -0
- package/dist/infinite-grid-2d.d.ts +73 -0
- package/dist/infinite-grid-2d.js +253 -0
- package/dist/infinite-grid-3d.d.ts +134 -0
- package/dist/infinite-grid-3d.js +407 -0
- package/dist/mesh-batch-3d.d.ts +10 -0
- package/dist/mesh-batch-3d.js +67 -0
- package/dist/shape2d-shader.d.ts +14 -0
- package/dist/shape2d-shader.js +288 -0
- package/dist/text-atlas.d.ts +176 -0
- package/dist/text-atlas.js +498 -0
- package/dist/text-font.d.ts +190 -0
- package/dist/text-font.js +261 -0
- package/dist/text-layout.d.ts +175 -0
- package/dist/text-layout.js +390 -0
- package/dist/text-msdf-baked.d.ts +41 -0
- package/dist/text-msdf-baked.js +117 -0
- package/dist/text-msdf-font.d.ts +85 -0
- package/dist/text-msdf-font.js +159 -0
- package/dist/text-msdf.d.ts +70 -0
- package/dist/text-msdf.js +242 -0
- package/dist/text-raster.d.ts +40 -0
- package/dist/text-raster.js +60 -0
- package/dist/text-sdf.d.ts +28 -0
- package/dist/text-sdf.js +119 -0
- package/dist/vector-math.d.ts +19 -0
- package/dist/vector-math.js +37 -0
- package/package.json +80 -4
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GPU text renderer.
|
|
3
|
+
*
|
|
4
|
+
* One instanced quad per glyph, sampling a glyph atlas page, exactly the shape reference
|
|
5
|
+
* sprite text takes: `TextPipeline` lays the glyphs out, `FontAtlas` holds the rasterised
|
|
6
|
+
* coverage, and the render pass instantiates one quad per `PositionedGlyph`
|
|
7
|
+
* (`the reference implementation`, `the reference implementation`).
|
|
8
|
+
*
|
|
9
|
+
* The atlas' pages are the layers of one 2D array texture, so growing past a single page costs no
|
|
10
|
+
* extra draw call: the instance carries the layer it samples, and `GlyphAtlas` recycles pages whose
|
|
11
|
+
* glyphs are no longer requested. Only the rectangles written since the last upload are copied, and
|
|
12
|
+
* each mip level is re-sent for the region that maps to it.
|
|
13
|
+
*
|
|
14
|
+
* The atlas stores a tintable alpha mask (RGB = 255, A = coverage) like reference `FontAtlas`,
|
|
15
|
+
* so the fragment stage only has to multiply the coverage into the span colour's alpha. When
|
|
16
|
+
* the glyph source encodes a distance field instead, the fragment stage reconstructs the edge
|
|
17
|
+
* from it, which keeps the glyph sharp at any magnification. Three
|
|
18
|
+
* placement spaces share the pipeline and are selected per instance:
|
|
19
|
+
*
|
|
20
|
+
* Layout and the atlas both work in font pixels and the two world spaces scale the result by
|
|
21
|
+
* `pixelSize`. That split is the reference `Label3D.font_size` / `pixel_size` pair, and it is
|
|
22
|
+
* what lets a block keep a fixed world size while its glyphs are rasterised at any resolution.
|
|
23
|
+
*
|
|
24
|
+
* - `world-plane` builds the block on a fixed local XY plane, so it foreshortens with the
|
|
25
|
+
* camera the way a reference `Label3D` does.
|
|
26
|
+
* - `world-billboard` builds it from the camera's own right/up axes, read out of the view
|
|
27
|
+
* matrix, so the block always faces the camera.
|
|
28
|
+
* - `screen` places it at a top-left origin pixel position with no projection at all.
|
|
29
|
+
*
|
|
30
|
+
* Depth is tested but never written. World text is therefore occluded by geometry in front of
|
|
31
|
+
* it, while screen text sits at the near plane and always wins; not writing depth keeps a
|
|
32
|
+
* glyph's translucent ink box from clipping the glyphs drawn after it.
|
|
33
|
+
*/
|
|
34
|
+
import { compute, draw, sampler, storage, texture } from "vgpu";
|
|
35
|
+
import { prepareShader } from "@vgpu/wgsl/prepare";
|
|
36
|
+
import { instanceGeometry } from "vgpu/scene/gpu";
|
|
37
|
+
import { instances } from "vgpu/scene";
|
|
38
|
+
import { depthBiasHelpers, identity, indirectArgs, INDIRECT_SHADER, trim, world, zeroGeometry } from "./gpu-instancing.js";
|
|
39
|
+
import { DISTANCE_CHANNELS, DEFAULT_ATLAS_SIZE, buildMipChain, dirtyAtLevel, downsampleDistancePage, downsamplePage, extractRegion, GlyphAtlas } from "./text-atlas.js";
|
|
40
|
+
import { DEFAULT_SDF_RANGE } from "./text-sdf.js";
|
|
41
|
+
import { layoutText, resolveTextAnchor } from "./text-layout.js";
|
|
42
|
+
import { quantizeRasterPx, rasterScaleFor, screenPixelsPerWorldUnit } from "./text-raster.js";
|
|
43
|
+
const DEFAULT_TEXT_DEPTH_BIAS = -0.001;
|
|
44
|
+
/** Levels of the atlas mip chain. A 512 px page reaches 64 px; beyond that the padding bleeds. */
|
|
45
|
+
const DEFAULT_MIP_LEVELS = 4;
|
|
46
|
+
/** Atlas pages. reference grows its `FontAtlasSet` the same way, one page at a time. */
|
|
47
|
+
const DEFAULT_ATLAS_PAGES = 4;
|
|
48
|
+
/**
|
|
49
|
+
* Default atlas budget, in texels: four 512 px pages' worth, however they are split. A 512 px page
|
|
50
|
+
* therefore gets four pages while a 1024 px one gets a single page, so raising `atlasSize` does not
|
|
51
|
+
* silently multiply the memory every glyph source holds.
|
|
52
|
+
*/
|
|
53
|
+
const DEFAULT_ATLAS_AREA = DEFAULT_ATLAS_PAGES * DEFAULT_ATLAS_SIZE * DEFAULT_ATLAS_SIZE;
|
|
54
|
+
/** Placement space, encoded in the low two bits of an instance's flags. */
|
|
55
|
+
const SPACE_WORLD_PLANE = 0;
|
|
56
|
+
const SPACE_WORLD_BILLBOARD = 1;
|
|
57
|
+
const SPACE_SCREEN = 2;
|
|
58
|
+
/** Third flag bit: skip depth comparison entirely and sit at the near plane. */
|
|
59
|
+
const FLAG_NO_DEPTH = 4;
|
|
60
|
+
function textShader(depthBias, encoding, distanceRange, atlasSize, maxLod) {
|
|
61
|
+
// The atlas holds either an anti-aliased coverage mask (reference `FontAtlas` layout) or a distance
|
|
62
|
+
// field. Coverage is already the answer; a distance has to be turned back into it, and the
|
|
63
|
+
// screen-space derivative is what keeps that transition one pixel wide however far the camera
|
|
64
|
+
// pushes the glyph — which is the whole point of storing a distance. Both encodings can also draw
|
|
65
|
+
// an outline: a distance field reads the true distance the encoder left in alpha, a coverage mask
|
|
66
|
+
// has none to read and is sampled instead.
|
|
67
|
+
const reconstruct = (stored, trueDistance, helper) => `${helper}@fragment fn fs_main(input: Out) -> @location(0) vec4f {
|
|
68
|
+
// One screen pixel of edge is the goal. Four separate things have to be right for that:
|
|
69
|
+
//
|
|
70
|
+
// 1. The derivative is taken on the UV coordinate, not on the decoded distance. The stored distance
|
|
71
|
+
// is quantised to 8 bits, so its own derivative jumps between zero and a quantisation step and
|
|
72
|
+
// stencils a regular dot pattern over the glyph. UV interpolation is smooth.
|
|
73
|
+
// 2. The range spans the whole encoded field, not half of it: stored 0 to 1 covers
|
|
74
|
+
// 2 * DISTANCE_RANGE texels.
|
|
75
|
+
// 3. The range is the average of both axes' derivatives, not their sum — the reference shader's
|
|
76
|
+
// 0.5 * dot(...). Each derivative already spans one screen pixel, so a sum counts it twice and
|
|
77
|
+
// leaves the ramp half a pixel wide, which drops the rim pixels a correct ramp puts at partial
|
|
78
|
+
// coverage.
|
|
79
|
+
// 4. The level the sample comes from scales the range back up: the chain is built in coverage, so a
|
|
80
|
+
// level's value describes one level-L texel, which already spans 2^L level-0 texels. Decoding it
|
|
81
|
+
// with the level-0 range costs about a tenth of a glyph's coverage at a minification step.
|
|
82
|
+
let texelsPerPixel = ATLAS_SIZE * max(fwidth(input.uv).x, fwidth(input.uv).y);
|
|
83
|
+
let lod = clamp(log2(max(texelsPerPixel, 1.0)), 0.0, MAX_LOD);
|
|
84
|
+
let texel = ${sampleLod()};
|
|
85
|
+
let unitRange = vec2f(2.0 * DISTANCE_RANGE * exp2(lod)) / vec2f(ATLAS_SIZE);
|
|
86
|
+
let screenTexSize = vec2f(1.0) / fwidth(input.uv);
|
|
87
|
+
let screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
|
|
88
|
+
let distance = screenPxRange * (${stored} - 0.5);
|
|
89
|
+
let fill = clamp(distance + 0.5, 0.0, 1.0);
|
|
90
|
+
// The outline reads the true distance in alpha rather than the median: the median is what keeps a
|
|
91
|
+
// corner sharp, and a corner is exactly where it disagrees with the true distance, so a ring
|
|
92
|
+
// placed from it would dent inwards on every corner. An unused alpha channel saturates, which
|
|
93
|
+
// leaves the ring at zero width.
|
|
94
|
+
//
|
|
95
|
+
// The ring's width arrives in font pixels and is added to a distance in the shader's own screen
|
|
96
|
+
// scale, so it goes through the same conversion the edge does. It is capped where the reference
|
|
97
|
+
// caps an outline — one texel short of the encoded band — because a wider ring has no distance
|
|
98
|
+
// left to read and would flood the quad instead of ringing it.
|
|
99
|
+
let outlinePx = input.outlineWidth * input.rasterScale / atlasTexelsPerPixel(input.uv);
|
|
100
|
+
let outlineRange = clamp(outlinePx, 0.0, (DISTANCE_RANGE - 1.0) * screenPxRange / (2.0 * DISTANCE_RANGE));
|
|
101
|
+
let outward = screenPxRange * (${trueDistance} - 0.5) + outlineRange;
|
|
102
|
+
let outer = clamp(outward + 0.5, 0.0, 1.0);
|
|
103
|
+
// No width means no ring, whatever alpha happens to hold: a page baked before the encoder wrote
|
|
104
|
+
// true distances there is opaque, and reading that as a distance would flood the whole quad.
|
|
105
|
+
let border = select(0.0, clamp(outer - fill, 0.0, 1.0) * input.outlineColor.a, input.outlineWidth > 0.0);
|
|
106
|
+
let coverage = border + fill * input.color.a * (1.0 - border);
|
|
107
|
+
let rgb = (input.color.rgb * fill * input.color.a + input.outlineColor.rgb * border) / max(coverage, 1e-6);
|
|
108
|
+
return vec4f(rgb, coverage);
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
// Every sample reads one layer of the atlas array: one page per layer, so a glyph beyond the first
|
|
112
|
+
// page costs no extra draw call.
|
|
113
|
+
//
|
|
114
|
+
// A coverage mask picks its level from the raster ladder rather than from the hardware: the ladder
|
|
115
|
+
// oversamples by at most one step, and blending that against a half-resolution level is exactly what
|
|
116
|
+
// would soften a stem that is already the right size. A distance field reads an explicit level too,
|
|
117
|
+
// but for the opposite reason — its levels are built in coverage and decoded against the range of the
|
|
118
|
+
// level they came from, so it has to sample the level this shader computed for that range.
|
|
119
|
+
const sampleLod = () => "textureSampleLevel(atlas, atlasSampler, input.uv, i32(input.layer + 0.5), lod)";
|
|
120
|
+
const fragment = encoding === "sdf"
|
|
121
|
+
? reconstruct("texel.r", "texel.a", "")
|
|
122
|
+
: encoding === "msdf"
|
|
123
|
+
? reconstruct("median3(texel.rgb)", "texel.a", `// The median of the three per-edge distances is the distance to the nearest edge, which is
|
|
124
|
+
// what preserves the corners a single channel rounds off.
|
|
125
|
+
fn median3(value: vec3f) -> f32 {
|
|
126
|
+
return max(min(value.r, value.g), min(max(value.r, value.g), value.b));
|
|
127
|
+
}
|
|
128
|
+
`)
|
|
129
|
+
: `@fragment fn fs_main(input: Out) -> @location(0) vec4f {
|
|
130
|
+
let layer = i32(input.layer + 0.5);
|
|
131
|
+
// The raster ladder oversamples by at most one step, so a glyph is usually magnified by less than
|
|
132
|
+
// a mip level — and at that scale blending level 0 with level 1 is what softens a stem that is
|
|
133
|
+
// already the right size. Flooring the level samples the stored raster until the block really has
|
|
134
|
+
// shrunk into the chain, which is the direction the chain exists for.
|
|
135
|
+
let texelsPerPixel = atlasTexelsPerPixel(input.uv);
|
|
136
|
+
let lod = clamp(floor(log2(max(texelsPerPixel, 1.0))), 0.0, MAX_LOD);
|
|
137
|
+
let texel = textureSampleLevel(atlas, atlasSampler, input.uv, layer, lod);
|
|
138
|
+
// A coverage mask carries no distance, so the ring is the furthest coverage any of eight
|
|
139
|
+
// directions reports at the outline's radius. Each step is taken through the screen derivative,
|
|
140
|
+
// so the ring stays round when the quad is stretched, and at width zero every sample lands back
|
|
141
|
+
// on the centre and leaves no ring at all.
|
|
142
|
+
let step = fwidth(input.uv) * (input.outlineWidth * input.rasterScale / atlasTexelsPerPixel(input.uv));
|
|
143
|
+
const DIAGONAL: f32 = 0.70710678;
|
|
144
|
+
var expanded = texel.a;
|
|
145
|
+
expanded = max(expanded, textureSample(atlas, atlasSampler, input.uv + vec2f(step.x, 0.0), layer).a);
|
|
146
|
+
expanded = max(expanded, textureSample(atlas, atlasSampler, input.uv - vec2f(step.x, 0.0), layer).a);
|
|
147
|
+
expanded = max(expanded, textureSample(atlas, atlasSampler, input.uv + vec2f(0.0, step.y), layer).a);
|
|
148
|
+
expanded = max(expanded, textureSample(atlas, atlasSampler, input.uv - vec2f(0.0, step.y), layer).a);
|
|
149
|
+
expanded = max(expanded, textureSample(atlas, atlasSampler, input.uv + vec2f(step.x, step.y) * DIAGONAL, layer).a);
|
|
150
|
+
expanded = max(expanded, textureSample(atlas, atlasSampler, input.uv + vec2f(step.x, -step.y) * DIAGONAL, layer).a);
|
|
151
|
+
expanded = max(expanded, textureSample(atlas, atlasSampler, input.uv + vec2f(-step.x, step.y) * DIAGONAL, layer).a);
|
|
152
|
+
expanded = max(expanded, textureSample(atlas, atlasSampler, input.uv + vec2f(-step.x, -step.y) * DIAGONAL, layer).a);
|
|
153
|
+
let fill = texel.a;
|
|
154
|
+
let border = select(0.0, clamp(expanded - fill, 0.0, 1.0) * input.outlineColor.a, input.outlineWidth > 0.0);
|
|
155
|
+
let coverage = border + fill * input.color.a * (1.0 - border);
|
|
156
|
+
let rgb = (input.color.rgb * fill * input.color.a + input.outlineColor.rgb * border) / max(coverage, 1e-6);
|
|
157
|
+
return vec4f(rgb, coverage);
|
|
158
|
+
}
|
|
159
|
+
`;
|
|
160
|
+
return `
|
|
161
|
+
struct Camera { viewProjection: mat4x4f, view: mat4x4f, viewportSize: vec2f }
|
|
162
|
+
@group(0) @binding(0) var<uniform> camera: Camera;
|
|
163
|
+
@group(0) @binding(1) var atlas: texture_2d_array<f32>;
|
|
164
|
+
@group(0) @binding(2) var atlasSampler: sampler;
|
|
165
|
+
${depthBiasHelpers(depthBias)}\nconst ATLAS_SIZE: f32 = ${atlasSize}.0;\nconst MAX_LOD: f32 = ${maxLod}.0;${encoding === "bitmap" ? "" : `\nconst DISTANCE_RANGE: f32 = ${distanceRange};`}
|
|
166
|
+
struct In {
|
|
167
|
+
@location(0) position: vec3f,
|
|
168
|
+
@location(1) world0: vec4f, @location(2) world1: vec4f,
|
|
169
|
+
@location(3) world2: vec4f, @location(4) world3: vec4f,
|
|
170
|
+
@location(5) origin: vec3f,
|
|
171
|
+
@location(6) offset: vec2f,
|
|
172
|
+
@location(7) size: vec2f,
|
|
173
|
+
@location(8) uv: vec4f,
|
|
174
|
+
@location(9) color: vec4f,
|
|
175
|
+
@location(10) flags: u32,
|
|
176
|
+
// The atlas layer arrives as a float: WebGPU wants an integer varying between the stages to be
|
|
177
|
+
// flat-interpolated, which the wgpu/naga backend behind the GPU tests rejects outright.
|
|
178
|
+
@location(11) layer: f32,
|
|
179
|
+
// The outline: a width in font pixels and the ring's colour, straight from the block's style.
|
|
180
|
+
@location(12) outlineWidth: f32,
|
|
181
|
+
@location(13) outlineColor: vec4f,
|
|
182
|
+
// Atlas texels per font pixel for the rung this glyph was rasterised on. Blocks in one frame can
|
|
183
|
+
// sit on different rungs, so it travels with the glyph instead of being a shader constant.
|
|
184
|
+
@location(14) rasterScale: f32,
|
|
185
|
+
}
|
|
186
|
+
struct Out { @builtin(position) clip: vec4f, @location(0) uv: vec2f, @location(1) color: vec4f, @location(2) layer: f32, @location(3) outlineWidth: f32, @location(4) outlineColor: vec4f, @location(5) rasterScale: f32 }
|
|
187
|
+
|
|
188
|
+
@vertex fn vs_main(input: In, @builtin(vertex_index) vertex: u32) -> Out {
|
|
189
|
+
let corners = array<vec2f, 6>(vec2f(0,0), vec2f(1,0), vec2f(0,1), vec2f(0,1), vec2f(1,0), vec2f(1,1));
|
|
190
|
+
let corner = corners[vertex];
|
|
191
|
+
// Layout space has y growing downwards: \`offset\` is the ink box centre and \`size\` its extent.
|
|
192
|
+
let local = (corner - vec2f(0.5)) * input.size + input.offset;
|
|
193
|
+
let space = input.flags & 3u;
|
|
194
|
+
|
|
195
|
+
var clip: vec4f;
|
|
196
|
+
if (space == ${SPACE_SCREEN}u) {
|
|
197
|
+
// \`origin.xy\` is a top-left origin pixel position; z is ignored.
|
|
198
|
+
let pixels = input.origin.xy + local;
|
|
199
|
+
clip = vec4f(pixels.x / camera.viewportSize.x * 2.0 - 1.0, 1.0 - pixels.y / camera.viewportSize.y * 2.0, 0.0, 1.0);
|
|
200
|
+
} else {
|
|
201
|
+
var worldPosition: vec3f;
|
|
202
|
+
if (space == ${SPACE_WORLD_BILLBOARD}u) {
|
|
203
|
+
// The rotation rows of the world-to-view matrix are the camera's axes in world space.
|
|
204
|
+
let right = vec3f(camera.view[0][0], camera.view[1][0], camera.view[2][0]);
|
|
205
|
+
let up = vec3f(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
|
|
206
|
+
worldPosition = input.origin + right * local.x - up * local.y;
|
|
207
|
+
} else {
|
|
208
|
+
// The instance world matrix carries the block's own plane basis: column 0 is the reading
|
|
209
|
+
// direction, column 1 the layout's down. Label3D.rotation lands on that basis, so the default
|
|
210
|
+
// identity keeps the plane facing +Z.
|
|
211
|
+
let axisX = input.world0.xyz;
|
|
212
|
+
let axisY = input.world1.xyz;
|
|
213
|
+
worldPosition = input.origin + axisX * local.x - axisY * local.y;
|
|
214
|
+
}
|
|
215
|
+
clip = camera.viewProjection * vec4f(worldPosition, 1.0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
var out: Out;
|
|
219
|
+
// Screen and explicitly depth-free blocks pin themselves to the near plane, where a
|
|
220
|
+
// "less-equal" test against anything already drawn succeeds.
|
|
221
|
+
let depth = select(apply_depth_bias(clip), 0.0, (input.flags & ${FLAG_NO_DEPTH}u) != 0u);
|
|
222
|
+
out.clip = vec4f(clip.xy, depth, clip.w);
|
|
223
|
+
out.uv = mix(input.uv.xy, input.uv.zw, corner);
|
|
224
|
+
out.color = input.color;
|
|
225
|
+
out.layer = input.layer;
|
|
226
|
+
out.outlineWidth = input.outlineWidth;
|
|
227
|
+
out.outlineColor = input.outlineColor;
|
|
228
|
+
out.rasterScale = input.rasterScale;
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Each glyph carries how many atlas texels one of its font pixels spans, and a screen pixel covers
|
|
233
|
+
// this many of them, which is what turns an outline width in font pixels into the screen pixels the
|
|
234
|
+
// reconstruction works in.
|
|
235
|
+
fn atlasTexelsPerPixel(uv: vec2f) -> f32 {
|
|
236
|
+
return max(f32(textureDimensions(atlas, 0).x) * max(fwidth(uv).x, fwidth(uv).y), 1e-5);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
${fragment}`;
|
|
240
|
+
}
|
|
241
|
+
/** `world-plane` blocks keep facing +Z until their own rotation lays them down. */
|
|
242
|
+
const IDENTITY_WORLD = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
243
|
+
/**
|
|
244
|
+
* The instance world matrix of a `world-plane` block, from the reference `Label3D.rotation` Euler
|
|
245
|
+
* order (Y, then X, then Z). Column 0 is the reading direction and column 1 the layout's down, so a
|
|
246
|
+
* `[-PI/2, 0, 0]` block lies flat on the XZ plane and reads from above.
|
|
247
|
+
*
|
|
248
|
+
* The basis travels in the instance world matrix rather than in two extra instance attributes: the
|
|
249
|
+
* text pipeline already uses the device's full vertex attribute budget.
|
|
250
|
+
*/
|
|
251
|
+
function textPlaneWorld(rotation) {
|
|
252
|
+
const [pitch, yaw, roll] = rotation;
|
|
253
|
+
if (pitch === 0 && yaw === 0 && roll === 0)
|
|
254
|
+
return IDENTITY_WORLD;
|
|
255
|
+
const cosRoll = Math.cos(roll);
|
|
256
|
+
const sinRoll = Math.sin(roll);
|
|
257
|
+
const cosPitch = Math.cos(pitch);
|
|
258
|
+
const sinPitch = Math.sin(pitch);
|
|
259
|
+
const cosYaw = Math.cos(yaw);
|
|
260
|
+
const sinYaw = Math.sin(yaw);
|
|
261
|
+
// Rz, then Rx, then Ry: the vector order that matches reference YXZ Euler convention.
|
|
262
|
+
let x = [cosRoll, sinRoll, 0];
|
|
263
|
+
let y = [-sinRoll, cosRoll, 0];
|
|
264
|
+
x = [x[0], x[1] * cosPitch - x[2] * sinPitch, x[1] * sinPitch + x[2] * cosPitch];
|
|
265
|
+
y = [y[0], y[1] * cosPitch - y[2] * sinPitch, y[1] * sinPitch + y[2] * cosPitch];
|
|
266
|
+
x = [x[0] * cosYaw + x[2] * sinYaw, x[1], -x[0] * sinYaw + x[2] * cosYaw];
|
|
267
|
+
y = [y[0] * cosYaw + y[2] * sinYaw, y[1], -y[0] * sinYaw + y[2] * cosYaw];
|
|
268
|
+
const z = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]];
|
|
269
|
+
return new Float32Array([
|
|
270
|
+
x[0], x[1], x[2], 0,
|
|
271
|
+
y[0], y[1], y[2], 0,
|
|
272
|
+
z[0], z[1], z[2], 0,
|
|
273
|
+
0, 0, 0, 1,
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
export class GpuText3D {
|
|
277
|
+
#gpu;
|
|
278
|
+
#font;
|
|
279
|
+
#atlas;
|
|
280
|
+
#glyphs;
|
|
281
|
+
#ids = [];
|
|
282
|
+
#bridge;
|
|
283
|
+
#draw;
|
|
284
|
+
#indirect;
|
|
285
|
+
#indirectCompute;
|
|
286
|
+
#atlasTexture;
|
|
287
|
+
#sampler;
|
|
288
|
+
#mipLevels;
|
|
289
|
+
#mipDownsample;
|
|
290
|
+
#uploadedRevisions = [];
|
|
291
|
+
#viewProjection = identity();
|
|
292
|
+
#view = identity();
|
|
293
|
+
#count = 0;
|
|
294
|
+
constructor(gpu, options) {
|
|
295
|
+
const capacity = options.capacity ?? 16384;
|
|
296
|
+
const atlasSize = options.atlasSize ?? DEFAULT_ATLAS_SIZE;
|
|
297
|
+
const pageCount = Math.max(1, Math.floor(options.pages ?? DEFAULT_ATLAS_AREA / (atlasSize * atlasSize)));
|
|
298
|
+
if (atlasSize % 64 !== 0) {
|
|
299
|
+
throw new RangeError("atlasSize must be a multiple of 64 so an atlas row meets WebGPU's 256 byte alignment");
|
|
300
|
+
}
|
|
301
|
+
this.#gpu = gpu;
|
|
302
|
+
this.#font = options.font;
|
|
303
|
+
// Pages are layers of one 2D array texture, so a glyph that lands outside the first page costs
|
|
304
|
+
// no extra draw call and no extra indirect buffer. The caller can still raise `atlasSize`
|
|
305
|
+
// instead, which costs memory for every page either way.
|
|
306
|
+
// The source owns the rasterisation scale, so two sources of one encoding can coexist in a frame.
|
|
307
|
+
// `onFull: "skip"` by default: a live cache that runs out of room should drop a glyph, not take the
|
|
308
|
+
// frame down. `skippedGlyphs` reports how many that cost.
|
|
309
|
+
this.#atlas = new GlyphAtlas(options.font, { size: atlasSize, maxPages: pageCount, onFull: options.onFull ?? "skip" });
|
|
310
|
+
// A chain cannot outrun the page: a 512 px atlas only has ten levels down to 1×1.
|
|
311
|
+
this.#mipLevels = Math.max(1, Math.min(options.mipLevels ?? DEFAULT_MIP_LEVELS, Math.floor(Math.log2(atlasSize)) + 1));
|
|
312
|
+
// A distance field's levels have to be filtered in coverage, or thin ink fades as the chain
|
|
313
|
+
// shrinks; a coverage mask's already are. See `downsampleDistancePage`.
|
|
314
|
+
const distanceChannels = DISTANCE_CHANNELS[this.#atlas.encoding];
|
|
315
|
+
this.#mipDownsample =
|
|
316
|
+
distanceChannels.length === 0
|
|
317
|
+
? downsamplePage
|
|
318
|
+
: (data, size) => downsampleDistancePage(data, size, this.#atlas.distanceRange ?? DEFAULT_SDF_RANGE, distanceChannels);
|
|
319
|
+
this.#glyphs = instances({
|
|
320
|
+
capacity,
|
|
321
|
+
attributes: {
|
|
322
|
+
// Named `origin`, not `position`: the shared quad mesh already claims `position` for the
|
|
323
|
+
// base layout, and vgpu rejects an attribute that both layouts define.
|
|
324
|
+
origin: "float32x3",
|
|
325
|
+
offset: "float32x2",
|
|
326
|
+
size: "float32x2",
|
|
327
|
+
uv: "float32x4",
|
|
328
|
+
color: "float32x4",
|
|
329
|
+
flags: "uint32",
|
|
330
|
+
layer: "float32",
|
|
331
|
+
// Font pixels, not layout units: the shader converts through the raster scale and the
|
|
332
|
+
// screen derivative, which is what keeps the ring proportional to the glyphs.
|
|
333
|
+
outlineWidth: "float32",
|
|
334
|
+
outlineColor: "float32x4",
|
|
335
|
+
// Atlas texels per font pixel for the rung this glyph landed on. It is per glyph because one
|
|
336
|
+
// frame mixes rungs: two blocks of the same font can be drawn at different screen sizes.
|
|
337
|
+
rasterScale: "float32",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
const mesh = zeroGeometry(gpu);
|
|
341
|
+
this.#bridge = instanceGeometry(gpu, this.#glyphs, { mesh });
|
|
342
|
+
this.#draw = draw(gpu, {
|
|
343
|
+
shader: prepareShader(textShader(options.depthBias ?? DEFAULT_TEXT_DEPTH_BIAS, this.#atlas.encoding, this.#atlas.distanceRange ?? DEFAULT_SDF_RANGE, atlasSize, this.#mipLevels - 1)),
|
|
344
|
+
geometry: this.#bridge.geometry,
|
|
345
|
+
instances: 0,
|
|
346
|
+
blend: "alpha",
|
|
347
|
+
cull: "none",
|
|
348
|
+
depth: { write: false, compare: "less-equal" },
|
|
349
|
+
});
|
|
350
|
+
this.#indirect = storage(gpu, 16, { access: "read-write", indirect: true });
|
|
351
|
+
this.#indirectCompute = compute(gpu, INDIRECT_SHADER);
|
|
352
|
+
indirectArgs(this.#indirect, 6, 0);
|
|
353
|
+
this.#atlasTexture = texture(gpu, {
|
|
354
|
+
kind: "2d-array",
|
|
355
|
+
size: [atlasSize, atlasSize],
|
|
356
|
+
layers: pageCount,
|
|
357
|
+
format: "rgba8unorm",
|
|
358
|
+
usage: ["texture_binding", "copy_dst"],
|
|
359
|
+
mipLevelCount: this.#mipLevels,
|
|
360
|
+
});
|
|
361
|
+
this.#sampler = sampler(gpu, {
|
|
362
|
+
magFilter: "linear",
|
|
363
|
+
minFilter: "linear",
|
|
364
|
+
mipmapFilter: "linear",
|
|
365
|
+
addressModeU: "clamp-to-edge",
|
|
366
|
+
addressModeV: "clamp-to-edge",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Glyphs dropped so far because every page was full. Non-zero means the atlas is too small for the
|
|
371
|
+
* frame — raise `atlasSize` or `pages` — and characters are quietly missing until it is fixed.
|
|
372
|
+
*/
|
|
373
|
+
get skippedGlyphs() {
|
|
374
|
+
return this.#atlas.skipped;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Lays out every block, fills the glyph atlas, and refreshes the instance buffer.
|
|
378
|
+
*
|
|
379
|
+
* `viewportSize` is what turns a world block's font pixels into screen pixels, and that ratio is
|
|
380
|
+
* what the raster rung follows. A caller with no target to measure (the text-reference page) can
|
|
381
|
+
* leave it out; world blocks then fall back to one pixel per unit and only lose sharpness.
|
|
382
|
+
*/
|
|
383
|
+
prepare(commands, camera) {
|
|
384
|
+
// One frame per call, so the atlas knows which pages it may recycle. See `GlyphAtlas.beginFrame`.
|
|
385
|
+
this.#atlas.beginFrame();
|
|
386
|
+
let index = 0;
|
|
387
|
+
for (const command of commands) {
|
|
388
|
+
const layout = layoutText(command.spans, {
|
|
389
|
+
color: command.color,
|
|
390
|
+
fontSize: command.fontSize,
|
|
391
|
+
fontFamily: command.fontFamily,
|
|
392
|
+
weight: command.weight,
|
|
393
|
+
style: command.style,
|
|
394
|
+
width: command.width,
|
|
395
|
+
// A relative line height stays relative all the way to layout, where each line resolves it
|
|
396
|
+
// against that line's own largest span; only an explicit one is absolute.
|
|
397
|
+
...(command.lineHeightScale === undefined
|
|
398
|
+
? { lineHeight: command.lineHeight }
|
|
399
|
+
: { lineHeightScale: command.lineHeightScale }),
|
|
400
|
+
letterSpacing: command.letterSpacing,
|
|
401
|
+
justify: command.justify,
|
|
402
|
+
lineBreak: command.lineBreak,
|
|
403
|
+
bounds: command.bounds,
|
|
404
|
+
anchor: resolveTextAnchor(command.anchor),
|
|
405
|
+
}, this.#font);
|
|
406
|
+
const space = command.space === "world-billboard" ? SPACE_WORLD_BILLBOARD : command.space === "screen" ? SPACE_SCREEN : SPACE_WORLD_PLANE;
|
|
407
|
+
const planeWorld = space === SPACE_WORLD_PLANE ? textPlaneWorld(command.rotation) : IDENTITY_WORLD;
|
|
408
|
+
// Screen text never takes part in depth testing, whatever the command asked for.
|
|
409
|
+
const flags = space | (command.depthTest && space !== SPACE_SCREEN ? 0 : FLAG_NO_DEPTH);
|
|
410
|
+
// The quad's size comes from the layout, not from the atlas. A distance field can be drawn at
|
|
411
|
+
// any size, and a prebaked page stores every glyph at one fixed raster whatever font size is
|
|
412
|
+
// asked for, so only the UV rectangle is tied to the stored texels.
|
|
413
|
+
const scale = space === SPACE_SCREEN ? 1 : command.pixelSize;
|
|
414
|
+
// One layout font pixel of a screen block covers one canvas pixel, so the atlas can ask for
|
|
415
|
+
// exactly what the display shows. A world block's font pixels cover however many screen pixels
|
|
416
|
+
// the camera gives them, and that is the number the raster rung has to follow — it is what keeps
|
|
417
|
+
// a block sharp at any distance instead of only at the size its source was rasterised at.
|
|
418
|
+
const screenPxPerFontPx = space === SPACE_SCREEN
|
|
419
|
+
? 1
|
|
420
|
+
: command.pixelSize * screenPixelsPerWorldUnit(camera.viewProjection, command.position, camera.viewportSize?.[1] ?? 0);
|
|
421
|
+
// A source that rasterises on demand follows the screen; a prebaked page cannot, and a source
|
|
422
|
+
// that named a scale of its own never falls below it (see `GlyphRasterizer.rasterScale`).
|
|
423
|
+
const dynamicRaster = this.#font.dynamicRaster ?? false;
|
|
424
|
+
const rasterFloor = this.#font.rasterScale ?? 1;
|
|
425
|
+
for (const glyph of layout.glyphs) {
|
|
426
|
+
const rasterScale = dynamicRaster
|
|
427
|
+
? rasterScaleFor(quantizeRasterPx(Math.max(glyph.fontSize * screenPxPerFontPx, glyph.fontSize * rasterFloor)), glyph.fontSize)
|
|
428
|
+
: undefined;
|
|
429
|
+
const entry = this.#atlas.glyph(glyph.character, glyph.face, glyph.fontSize, rasterScale);
|
|
430
|
+
if (entry === undefined)
|
|
431
|
+
continue;
|
|
432
|
+
const id = this.#ids[index] ?? this.#addInstance(index);
|
|
433
|
+
this.#glyphs.set(id, {
|
|
434
|
+
origin: command.position,
|
|
435
|
+
offset: [glyph.center[0] * scale, glyph.center[1] * scale],
|
|
436
|
+
size: [glyph.width * scale, glyph.height * scale],
|
|
437
|
+
uv: entry.uv,
|
|
438
|
+
color: glyph.color,
|
|
439
|
+
flags,
|
|
440
|
+
layer: entry.page,
|
|
441
|
+
rasterScale: entry.rasterScale,
|
|
442
|
+
outlineWidth: command.outlineWidth,
|
|
443
|
+
outlineColor: command.outlineColor,
|
|
444
|
+
});
|
|
445
|
+
// The matrix always reflects this block's own plane, because instances are reused across
|
|
446
|
+
// blocks and a stale basis would tilt a block that asked for none.
|
|
447
|
+
this.#glyphs.setWorld(id, planeWorld);
|
|
448
|
+
index++;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
trim(this.#glyphs, this.#ids, index);
|
|
452
|
+
this.#count = this.#bridge.publish();
|
|
453
|
+
indirectArgs(this.#indirect, 6, this.#count);
|
|
454
|
+
this.#viewProjection = camera.viewProjection;
|
|
455
|
+
this.#view = camera.view;
|
|
456
|
+
this.#uploadAtlas();
|
|
457
|
+
}
|
|
458
|
+
#addInstance(index) {
|
|
459
|
+
const id = this.#glyphs.add({ origin: [0, 0, 0], offset: [0, 0], size: [0, 0], uv: [0, 0, 0, 0], color: [0, 0, 0, 0], flags: 0, layer: 0, rasterScale: 1, outlineWidth: 0, outlineColor: [0, 0, 0, 0] });
|
|
460
|
+
world(this.#glyphs, id);
|
|
461
|
+
this.#ids[index] = id;
|
|
462
|
+
return id;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Copies every page that changed into its layer of the array texture, rebuilding the whole mip
|
|
466
|
+
* chain on the CPU and uploading only the rectangles that were actually written. WebGPU has no
|
|
467
|
+
* `generateMipmap` and vgpu exposes none, so the levels are filtered here — the coverage channel
|
|
468
|
+
* averages correctly, and a distance field keeps the encoder's own resampling. Uploads use the
|
|
469
|
+
* native queue directly, which is what `Texture.gpu` exists for.
|
|
470
|
+
*/
|
|
471
|
+
#uploadAtlas() {
|
|
472
|
+
for (const page of this.#atlas.pages) {
|
|
473
|
+
if (page.revision === (this.#uploadedRevisions[page.index] ?? -1))
|
|
474
|
+
continue;
|
|
475
|
+
const dirty = page.dirty;
|
|
476
|
+
const chain = buildMipChain(page.data, page.size, this.#mipLevels, this.#mipDownsample);
|
|
477
|
+
for (let level = 0; level < chain.length; level++) {
|
|
478
|
+
const mip = chain[level];
|
|
479
|
+
const rect = dirty === undefined ? { x: 0, y: 0, width: mip.size, height: mip.size } : dirtyAtLevel(dirty, level, page.size);
|
|
480
|
+
const rows = extractRegion(mip.data, mip.size, rect);
|
|
481
|
+
this.#gpu.gpu.queue.writeTexture({ texture: this.#atlasTexture.gpu, mipLevel: level, origin: [rect.x, rect.y, page.index] }, rows.data, { bytesPerRow: rows.bytesPerRow, rowsPerImage: rect.height }, { width: rect.width, height: rect.height, depthOrArrayLayers: 1 });
|
|
482
|
+
}
|
|
483
|
+
page.dirty = undefined;
|
|
484
|
+
this.#uploadedRevisions[page.index] = page.revision;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Refreshes this draw's indirect arguments inside an already-open compute pass, so a frame
|
|
489
|
+
* only needs one pass for every indirect draw in it.
|
|
490
|
+
*/
|
|
491
|
+
dispatchIndirect(pass) {
|
|
492
|
+
this.#indirectCompute.set({ params: { vertexCount: 6, instanceCount: this.#count }, args: this.#indirect });
|
|
493
|
+
pass.dispatch(this.#indirectCompute, 1);
|
|
494
|
+
}
|
|
495
|
+
draw(pass) {
|
|
496
|
+
if (this.#count === 0)
|
|
497
|
+
return;
|
|
498
|
+
this.#draw.set({
|
|
499
|
+
camera: { viewProjection: this.#viewProjection, view: this.#view, viewportSize: pass.target.size },
|
|
500
|
+
atlas: this.#atlasTexture,
|
|
501
|
+
atlasSampler: this.#sampler,
|
|
502
|
+
});
|
|
503
|
+
pass.draw(this.#draw, { indirect: this.#indirect });
|
|
504
|
+
}
|
|
505
|
+
get glyphCount() {
|
|
506
|
+
return this.#count;
|
|
507
|
+
}
|
|
508
|
+
destroy() {
|
|
509
|
+
this.#draw.dispose();
|
|
510
|
+
this.#bridge.destroy();
|
|
511
|
+
this.#indirectCompute.dispose();
|
|
512
|
+
this.#atlasTexture.dispose();
|
|
513
|
+
}
|
|
514
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export * from "./debug-draw-3d.js";
|
|
2
|
+
export * from "./camera-2d.js";
|
|
3
|
+
export * from "./frame-draw-list.js";
|
|
4
|
+
export * from "./frame-draw-list-2d.js";
|
|
5
|
+
export * from "./color-space.js";
|
|
6
|
+
export * from "./curve.js";
|
|
7
|
+
export * from "./geometry-generators.js";
|
|
8
|
+
export * from "./gpu-debug-draw-3d.js";
|
|
9
|
+
export * from "./gpu-debug-draw-2d.js";
|
|
10
|
+
export * from "./gpu-text-3d.js";
|
|
11
|
+
export * from "./infinite-grid-3d.js";
|
|
12
|
+
export * from "./infinite-grid-2d.js";
|
|
13
|
+
export * from "./mesh-batch-3d.js";
|
|
14
|
+
export * from "./text-atlas.js";
|
|
15
|
+
export * from "./text-font.js";
|
|
16
|
+
export * from "./text-layout.js";
|
|
17
|
+
export * from "./text-sdf.js";
|
|
18
|
+
export * from "./text-msdf.js";
|
|
19
|
+
export * from "./text-msdf-font.js";
|
|
20
|
+
export * from "./text-msdf-baked.js";
|
|
21
|
+
export type { CircleBasis } from "./vector-math.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export * from "./debug-draw-3d.js";
|
|
2
|
+
export * from "./camera-2d.js";
|
|
3
|
+
export * from "./frame-draw-list.js";
|
|
4
|
+
export * from "./frame-draw-list-2d.js";
|
|
5
|
+
export * from "./color-space.js";
|
|
6
|
+
export * from "./curve.js";
|
|
7
|
+
export * from "./geometry-generators.js";
|
|
8
|
+
export * from "./gpu-debug-draw-3d.js";
|
|
9
|
+
export * from "./gpu-debug-draw-2d.js";
|
|
10
|
+
export * from "./gpu-text-3d.js";
|
|
11
|
+
export * from "./infinite-grid-3d.js";
|
|
12
|
+
export * from "./infinite-grid-2d.js";
|
|
13
|
+
export * from "./mesh-batch-3d.js";
|
|
14
|
+
export * from "./text-atlas.js";
|
|
15
|
+
export * from "./text-font.js";
|
|
16
|
+
export * from "./text-layout.js";
|
|
17
|
+
export * from "./text-sdf.js";
|
|
18
|
+
export * from "./text-msdf.js";
|
|
19
|
+
export * from "./text-msdf-font.js";
|
|
20
|
+
export * from "./text-msdf-baked.js";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shader-driven infinite 2D grid, ported from `infinite_grid_2d.gdshader`
|
|
3
|
+
* (`hardalex/the reference grid library`, the same reference `InfiniteGrid3D` comes from).
|
|
4
|
+
*
|
|
5
|
+
* The reference is a full-viewport `ColorRect` in a canvas layer: the fragment shader maps screen
|
|
6
|
+
* pixels back to `Camera2D` world coordinates, so there is no geometry to run out of and no
|
|
7
|
+
* projection involved. The LOD ladder is the reference's: one decimal level per zoom octave, three
|
|
8
|
+
* layers (finer, current, coarser) blended with de-duplicated alpha, so overlapping levels cannot
|
|
9
|
+
* stack brightness.
|
|
10
|
+
*
|
|
11
|
+
* Unlike the reference shader, `world2d` has +y up while the framebuffer's y points down, so
|
|
12
|
+
* `screen_to_world` mirrors the y offset. Everything else — cell size, pixel width, LOD range,
|
|
13
|
+
* colors and the debug palette — mirrors the reference uniforms one to one.
|
|
14
|
+
*/
|
|
15
|
+
import { type FramePass, type Gpu } from "vgpu";
|
|
16
|
+
export type InfiniteGrid2DOptions = {
|
|
17
|
+
/** World units per base cell. The LOD ladder multiplies this by powers of ten. */
|
|
18
|
+
readonly cellSize?: number;
|
|
19
|
+
/** Screen pixels the finest visible cell must span before the LOD steps up. */
|
|
20
|
+
readonly minPixelsBetweenCells?: number;
|
|
21
|
+
readonly lineWidthPixels?: number;
|
|
22
|
+
/** How many decimal LOD levels exist below the current one. */
|
|
23
|
+
readonly lodFinerLevels?: number;
|
|
24
|
+
/** Total decimal LOD levels, including the current one. */
|
|
25
|
+
readonly lodTotalLevels?: number;
|
|
26
|
+
readonly thinLineColor?: readonly [number, number, number, number];
|
|
27
|
+
readonly thickLineColor?: readonly [number, number, number, number];
|
|
28
|
+
readonly debugLodColors?: boolean;
|
|
29
|
+
};
|
|
30
|
+
export type ResolvedGrid2DOptions = {
|
|
31
|
+
readonly cellSize: number;
|
|
32
|
+
readonly minPixelsBetweenCells: number;
|
|
33
|
+
readonly lineWidthPixels: number;
|
|
34
|
+
readonly lodFinerLevels: number;
|
|
35
|
+
readonly lodTotalLevels: number;
|
|
36
|
+
readonly thinLineColor: readonly [number, number, number, number];
|
|
37
|
+
readonly thickLineColor: readonly [number, number, number, number];
|
|
38
|
+
readonly debugLodColors: boolean;
|
|
39
|
+
};
|
|
40
|
+
/** Reference defaults from `infinite_grid_2d.gd`, which differs from the 3D shader on purpose. */
|
|
41
|
+
export declare const DEFAULT_GRID_2D_OPTIONS: ResolvedGrid2DOptions;
|
|
42
|
+
/** The 2D camera state the shader reads, in `world2d` units and canvas pixels per unit. */
|
|
43
|
+
export type Grid2DCamera = {
|
|
44
|
+
readonly position: readonly [number, number];
|
|
45
|
+
/** Canvas pixels per world unit. */
|
|
46
|
+
readonly zoom: number;
|
|
47
|
+
/** Camera rotation in radians; the gallery keeps it at 0. */
|
|
48
|
+
readonly rotation?: number;
|
|
49
|
+
};
|
|
50
|
+
/** Validates and freezes the options; the shader's own clamps then only guard the LOD range. */
|
|
51
|
+
export declare function resolveGrid2DOptions(options?: InfiniteGrid2DOptions): ResolvedGrid2DOptions;
|
|
52
|
+
/**
|
|
53
|
+
* The decimal power range the shader may pick from: `lodFinerLevels` below the current level and
|
|
54
|
+
* `lodTotalLevels` in total, matching `_apply_lod_range_parameters`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function grid2dLodPowers(options: Pick<ResolvedGrid2DOptions, "lodFinerLevels" | "lodTotalLevels">): {
|
|
57
|
+
min: number;
|
|
58
|
+
max: number;
|
|
59
|
+
};
|
|
60
|
+
export declare class InfiniteGrid2D {
|
|
61
|
+
#private;
|
|
62
|
+
constructor(gpu: Gpu, options?: InfiniteGrid2DOptions);
|
|
63
|
+
/** Reference-style property updates: every field is a per-frame uniform. */
|
|
64
|
+
setOptions(options: InfiniteGrid2DOptions): void;
|
|
65
|
+
/** Demo extension: scales both line alphas without discarding their reference ratio. */
|
|
66
|
+
setOpacity(opacity: number): void;
|
|
67
|
+
/**
|
|
68
|
+
* Draws the grid for one frame. `viewportSize` is the target size in canvas pixels, the same
|
|
69
|
+
* space the 2D projection uses, so the grid and the 2D primitives stay aligned.
|
|
70
|
+
*/
|
|
71
|
+
draw(pass: FramePass, camera: Grid2DCamera, viewportSize: readonly [number, number]): void;
|
|
72
|
+
destroy(): void;
|
|
73
|
+
}
|