@solidrt/2d 0.0.56 → 0.0.57

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.
@@ -1,6 +1,6 @@
1
1
  // The pure oversample math: arithmetic on plain numbers, no GPU or window
2
2
  // imports, so the headless check (checks/oversample-check.ts) drives it
3
- // directly. oversample.ts wraps it with the device limits; components.tsx
3
+ // directly. oversample.ts wraps it with the device limits; components/
4
4
  // adds the measured inputs (window box, display scale, camera).
5
5
 
6
6
  /** Tolerance under a whole number when rounding a display scale up, so a
package/src/records.ts CHANGED
@@ -20,26 +20,14 @@
20
20
  // (addSprite/setSprite/...) are shared with the node layer; picking here is
21
21
  // the JS reverse walk (pointInSprite), since records have no nodes.
22
22
  import { getOwner, onCleanup } from "@solidrt/core"
23
- import {
24
- beginBufferWrite,
25
- createBuffer,
26
- createPipelineTexture,
27
- destroyBuffer,
28
- destroyTexture,
29
- endBufferWrite,
30
- setDraw,
31
- setTargetParams,
32
- setTargetSize,
33
- } from "@solidrt/core/gpu"
23
+ import { beginBufferWrite, createBuffer, destroyBuffer, endBufferWrite } from "@solidrt/core/gpu"
34
24
  import type { BufferId, TextureId } from "@solidrt/core/gpu"
35
- import { checkCamera, projectCamera, unprojectCamera } from "./camera.ts"
36
25
  import { FULL_FRAME, writeFrame } from "./frames.ts"
37
- import { spriteDispatch } from "./dispatch.ts"
38
26
  import { checkTint, readFrame } from "./layer.ts"
39
- import type { LayerBase, LayerPointerListener, Sprite, SpriteHandlers, SpriteLayerOptions, SpriteOptions, SpriteState } from "./layer.ts"
27
+ import type { LayerBase, Sprite, SpriteLayerOptions, SpriteOptions, SpriteState } from "./layer.ts"
40
28
  import { pointInSprite } from "./pick.ts"
41
- import { checkOversample, thrashSentinel } from "./oversample.ts"
42
- import { FRAGMENT, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
29
+ import { createSpritePipeline, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
30
+ import { createViews } from "./views.ts"
43
31
 
44
32
  // Floats per instance record:
45
33
  // [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA]
@@ -92,33 +80,21 @@ export type RecordLayer = LayerBase & {
92
80
  }
93
81
 
94
82
  /**
95
- * Create a records layer rendering into a `width` x `height` texture from
96
- * one atlas texture (same target shape as createSpriteLayer; the atlas is
97
- * NOT owned). Disposed automatically with the owning reactive scope (opt
98
- * out with `{ autoFree: false }`).
83
+ * Create a records layer over one atlas texture (the atlas is NOT owned).
84
+ * Like createSpriteLayer it renders nothing by itself: `createView` is
85
+ * where it shows. Disposed automatically with the owning reactive scope
86
+ * (opt out with `{ autoFree: false }`).
99
87
  */
100
- export function createRecordLayer(
101
- width: number,
102
- height: number,
103
- atlas: TextureId,
104
- opts?: RecordLayerOptions,
105
- ): RecordLayer {
88
+ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions): RecordLayer {
106
89
  let capacity = opts?.capacity ?? 1024
107
90
  if (!(capacity > 0 && Number.isInteger(capacity))) {
108
91
  throw new Error(`createRecordLayer: capacity must be a positive integer, got ${capacity}`)
109
92
  }
110
93
  let label = opts?.label ?? "sprites"
111
- // One unit quad (triangle strip), reused by every instance.
112
- let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
113
- label: `${label}-quad`,
114
- autoFree: false,
115
- })
116
94
  let records: BufferId = createBuffer(capacity * FLOATS_PER_SPRITE * 4, {
117
95
  label: `${label}-records`,
118
96
  autoFree: false,
119
97
  })
120
- let oversample = opts?.oversample ?? 1
121
- checkOversample("createRecordLayer", oversample, width, height)
122
98
  let tint = opts?.tint ?? [1, 1, 1, 1]
123
99
  checkTint("createRecordLayer", tint)
124
100
  let orderBy = opts?.orderBy
@@ -128,37 +104,8 @@ export function createRecordLayer(
128
104
  : orderBy === "y"
129
105
  ? { field: Y_FIELD_OFFSET }
130
106
  : { field: orderBy.field, descending: orderBy.descending }
131
- let thrash = thrashSentinel(`record layer "${label}"`)
132
- let texture = createPipelineTexture(
133
- VERTEX,
134
- FRAGMENT,
135
- width * oversample,
136
- height * oversample,
137
- { uViewport: [width, height], uCamera: [0, 0, 1, 1], uCameraRot: [1, 0, 0, 0], uTint: tint },
138
- {
139
- label,
140
- topology: "triangle-strip",
141
- vertexCount: 4,
142
- attributes: [{ name: "aPos", format: "vec2" }],
143
- buffer: quad,
144
- instanceAttributes: INSTANCE_ATTRIBUTES,
145
- instanceBuffer: records,
146
- instanceOrder,
147
- instanceCount: 0,
148
- blend: "alpha",
149
- textures: { uAtlas: atlas },
150
- clearColor: opts?.clearColor ?? [0, 0, 0, 0],
151
- autoFree: false,
152
- },
153
- )
107
+ let gpu = createSpritePipeline(label, VERTEX, INSTANCE_ATTRIBUTES)
154
108
 
155
- // Camera state, mirrored for picking (the inverse mapping).
156
- let camX = 0
157
- let camY = 0
158
- let camZoom = 1
159
- let camRot = 0
160
- let camPivotX = 0
161
- let camPivotY = 0
162
109
  let disposed = false
163
110
  let dirty = false
164
111
  let scheduled = false
@@ -167,7 +114,7 @@ export function createRecordLayer(
167
114
  // The GPU buffer's record capacity; the canonical array grows ahead of it
168
115
  // (addSprite) and the publish catches the buffer up: a larger buffer is
169
116
  // created, written in full, swapped in, and the old one destroyed. The
170
- // entry holds the old buffer alive until the swap lands, so the destroy
117
+ // entries hold the old buffer alive until the swaps land, so the destroy
171
118
  // is safe to issue right after.
172
119
  let gpuCapacity = capacity
173
120
  let flush = () => {
@@ -185,7 +132,8 @@ export function createRecordLayer(
185
132
  out.set(layer.records.subarray(0, count * FLOATS_PER_SPRITE))
186
133
  endBufferWrite(target, count * FLOATS_PER_SPRITE * 4)
187
134
  if (grown !== null) {
188
- setDraw(texture, { instanceBuffer: grown, instanceCount: count })
135
+ views.setBuffers({ instanceBuffer: grown })
136
+ views.setCount(count)
189
137
  if (instanceOrder !== undefined) {
190
138
  // The growth publish above landed BEFORE the swap (the entry must
191
139
  // never point at an unwritten buffer), so the order had not yet
@@ -200,7 +148,7 @@ export function createRecordLayer(
200
148
  records = grown
201
149
  published = count
202
150
  } else if (count !== published) {
203
- setDraw(texture, { instanceCount: count })
151
+ views.setCount(count)
204
152
  published = count
205
153
  }
206
154
  }
@@ -239,71 +187,31 @@ export function createRecordLayer(
239
187
  }
240
188
  }
241
189
 
242
- let listeners = new Set<LayerPointerListener>()
190
+ let views = createViews({
191
+ label,
192
+ pipeline: gpu.pipeline,
193
+ quad: gpu.quad,
194
+ atlas,
195
+ buffers: () => ({ instanceBuffer: records }),
196
+ count: () => published,
197
+ tint: () => tint,
198
+ pick: (x, y) => layer.pick(x, y),
199
+ order: instanceOrder,
200
+ })
243
201
 
244
202
  let layer: RecordLayer = {
245
- texture,
246
- handlers: undefined as unknown as SpriteHandlers,
247
203
  get count() {
248
204
  return layer._order.length
249
205
  },
250
- get width() {
251
- return width
252
- },
253
- get height() {
254
- return height
255
- },
256
- setSize(w, h) {
257
- if (disposed || (w === width && h === height)) return
258
- checkOversample("setSize", oversample, w, h)
259
- width = w
260
- height = h
261
- setTargetSize(texture, w * oversample, h * oversample)
262
- setTargetParams(texture, { uViewport: [w, h] })
263
- },
264
- listen(listener) {
265
- listeners.add(listener)
266
- return () => {
267
- listeners.delete(listener)
268
- }
269
- },
270
- get oversample() {
271
- return oversample
272
- },
273
- setOversample(n) {
274
- if (disposed || n === oversample) return
275
- checkOversample("setOversample", n, width, height)
276
- thrash()
277
- oversample = n
278
- setTargetSize(texture, width * n, height * n)
279
- },
280
- setCamera(update) {
281
- if (disposed) return
282
- checkCamera(update)
283
- if (update.x !== undefined) camX = update.x
284
- if (update.y !== undefined) camY = update.y
285
- if (update.zoom !== undefined) camZoom = update.zoom
286
- if (update.rotation !== undefined) camRot = update.rotation
287
- if (update.pivotX !== undefined) camPivotX = update.pivotX
288
- if (update.pivotY !== undefined) camPivotY = update.pivotY
289
- setTargetParams(texture, {
290
- uCamera: [camX, camY, camZoom, camZoom],
291
- uCameraRot: [Math.cos(camRot), Math.sin(camRot), camPivotX, camPivotY],
292
- })
293
- },
294
- camera() {
295
- return { x: camX, y: camY, zoom: camZoom, rotation: camRot, pivotX: camPivotX, pivotY: camPivotY }
296
- },
297
- project(x, y) {
298
- return projectCamera(layer.camera(), x, y)
299
- },
300
- unproject(x, y) {
301
- return unprojectCamera(layer.camera(), x, y)
302
- },
303
206
  setTint(next) {
304
207
  if (disposed) return
305
208
  checkTint("setTint", next)
306
- setTargetParams(texture, { uTint: next })
209
+ tint = next
210
+ views.setTint(next)
211
+ },
212
+ createView(vopts) {
213
+ if (disposed) throw new Error("createView: layer is disposed")
214
+ return views.create(vopts)
307
215
  },
308
216
  pick(x, y) {
309
217
  // Topmost first: reverse draw order, exact rotated-rect containment.
@@ -317,18 +225,14 @@ export function createRecordLayer(
317
225
  }
318
226
  return out
319
227
  },
320
- handlersFor(layout) {
321
- return dispatch(layout)
322
- },
323
228
  dispose() {
324
229
  if (disposed) return
325
230
  disposed = true
326
- listeners.clear()
327
231
  for (let sprite of layer._order) sprite.layer = null
328
232
  layer._order.length = 0
329
- destroyTexture(texture)
233
+ views.dispose()
330
234
  destroyBuffer(records)
331
- destroyBuffer(quad)
235
+ gpu.dispose()
332
236
  },
333
237
  _add(opts) {
334
238
  if (opts?.parent) {
@@ -405,15 +309,6 @@ export function createRecordLayer(
405
309
  },
406
310
  _order: [],
407
311
  }
408
- let dispatch = spriteDispatch({
409
- size: () => [width, height],
410
- camera: () => layer.camera(),
411
- pick: (x, y) => layer.pick(x, y),
412
- root: layer,
413
- listeners,
414
- })
415
- layer.handlers = dispatch(null)
416
-
417
312
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
418
313
  return layer
419
314
  }
package/src/shaders.ts CHANGED
@@ -20,8 +20,8 @@
20
20
  // projectCamera in camera.ts. The differential checks guard the JS side
21
21
  // against oracles but NOT against these shaders - if you touch one
22
22
  // rotation, touch all.
23
- import { glsl } from "@solidrt/core/gpu"
24
- import type { InstanceAttribute, VertexAttribute } from "@solidrt/core/gpu"
23
+ import { compileShader, createBuffer, createRenderPipeline, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyShader, glsl, linkProgram } from "@solidrt/core/gpu"
24
+ import type { BufferId, InstanceAttribute, RenderPipelineId, VertexAttribute } from "@solidrt/core/gpu"
25
25
 
26
26
  export let VERTEX = glsl`
27
27
  in vec2 aPos;
@@ -31,6 +31,7 @@ export let VERTEX = glsl`
31
31
  in float iRot;
32
32
  in vec4 iTint;
33
33
  out vec2 vUv;
34
+ flat out vec4 vFrame;
34
35
  out vec4 vTint;
35
36
  uniform vec2 uViewport;
36
37
  uniform vec4 uCamera;
@@ -45,6 +46,7 @@ export let VERTEX = glsl`
45
46
  // World and clip are both y-down, so the mapping carries no flip.
46
47
  gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
47
48
  vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
49
+ vFrame = iUv;
48
50
  vTint = iTint;
49
51
  }
50
52
  `
@@ -52,14 +54,31 @@ export let VERTEX = glsl`
52
54
  // uTint is the LAYER tint, multiplied over the per-instance vTint (identity
53
55
  // [1, 1, 1, 1] - GLSL uniforms default to ZERO, so every pipeline creation
54
56
  // must pin it explicitly or everything renders transparent black).
57
+ //
58
+ // The sample is clamped to its frame, pulled in by half a texel: a quad at
59
+ // a fractional position lands edge samples so close to the frame's edge
60
+ // that a linear tap (and float interpolation under nearest) reaches the
61
+ // cell next door, painting a one-texel line of the neighbour along the
62
+ // sprite's edge. The clamp keeps the quad's texel mapping 1:1 and only
63
+ // moves those edge samples onto the edge texel, so touching cells do not
64
+ // bleed and no gutter or inset is needed at mip level 0. It cannot help a
65
+ // mip chain, whose texels straddle the cell edge before sampling - that is
66
+ // okf/backlog/2d-atlas-extrude.md. min/max because a flipped frame stores
67
+ // its UVs swapped, and the inner min/max because a sub-texel frame would
68
+ // otherwise hand clamp() a lower bound above its upper one.
55
69
  export let FRAGMENT = glsl`
56
70
  in vec2 vUv;
71
+ flat in vec4 vFrame;
57
72
  in vec4 vTint;
58
73
  uniform sampler2D uAtlas;
59
74
  uniform vec4 uTint;
60
75
 
61
76
  void main() {
62
- fragColor = texture(uAtlas, vUv) * vTint * uTint;
77
+ vec2 halfTexel = 0.5 / vec2(textureSize(uAtlas, 0));
78
+ vec2 lo = min(vFrame.xy, vFrame.zw) + halfTexel;
79
+ vec2 hi = max(vFrame.xy, vFrame.zw) - halfTexel;
80
+ vec2 uv = clamp(vUv, min(lo, hi), max(lo, hi));
81
+ fragColor = texture(uAtlas, uv) * vTint * uTint;
63
82
  }
64
83
  `
65
84
 
@@ -80,6 +99,7 @@ export let VERTEX_SPLIT = glsl`
80
99
  in vec4 iUv;
81
100
  in vec4 iTint;
82
101
  out vec2 vUv;
102
+ flat out vec4 vFrame;
83
103
  out vec4 vTint;
84
104
  uniform vec2 uViewport;
85
105
  uniform vec4 uCamera;
@@ -94,6 +114,7 @@ export let VERTEX_SPLIT = glsl`
94
114
  // World and clip are both y-down, so the mapping carries no flip.
95
115
  gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
96
116
  vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
117
+ vFrame = iUv;
97
118
  vTint = iTint;
98
119
  }
99
120
  `
@@ -108,3 +129,44 @@ export const INSTANCE_ATTRIBUTES_SPLIT: InstanceAttribute[] = [
108
129
  { name: "iTint", format: "vec4", slot: 1 },
109
130
  { name: "iRenderOrder", format: "f32", slot: 1 },
110
131
  ]
132
+
133
+ /** What a layer's targets draw with: the unit quad every instance reuses
134
+ * and the pipeline over one of the two layouts above. */
135
+ export type SpritePipeline = { quad: BufferId; pipeline: RenderPipelineId; dispose(): void }
136
+
137
+ /**
138
+ * Compile one sprite pipeline: `vertex` with its matching attribute list
139
+ * (VERTEX + INSTANCE_ATTRIBUTES, or the split pair) over the shared
140
+ * fragment stage, alpha-blended triangle strips. Spelled out (not the
141
+ * fused createPipelineTexture) so a layer's targets - its own and every
142
+ * view - add entries over the one pipeline. The program lives as long as
143
+ * the pipeline; `dispose` frees both and the quad.
144
+ */
145
+ export function createSpritePipeline(label: string, vertex: string, instanceAttributes: InstanceAttribute[]): SpritePipeline {
146
+ // One unit quad (triangle strip), reused by every instance.
147
+ let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
148
+ label: `${label}-quad`,
149
+ autoFree: false,
150
+ })
151
+ let vs = compileShader("vertex", vertex, { header: true })
152
+ let fs = compileShader("fragment", FRAGMENT, { header: true })
153
+ let program = linkProgram(vs, fs, { label })
154
+ destroyShader(vs)
155
+ destroyShader(fs)
156
+ let pipeline = createRenderPipeline(program, {
157
+ label,
158
+ topology: "triangle-strip",
159
+ attributes: [{ name: "aPos", format: "vec2" }],
160
+ instanceAttributes,
161
+ blend: "alpha",
162
+ })
163
+ return {
164
+ quad,
165
+ pipeline,
166
+ dispose() {
167
+ destroyRenderPipeline(pipeline)
168
+ destroyProgram(program)
169
+ destroyBuffer(quad)
170
+ },
171
+ }
172
+ }