@solidrt/core 0.0.55 → 0.0.56

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,4 +1,4 @@
1
- import { createRoot, createSignal } from "@solidjs/signals"
1
+ import { createSignal, runWithOwner } from "@solidjs/signals"
2
2
  import { on } from "srt:events"
3
3
  import { onPointerMove } from "./core"
4
4
  import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
@@ -12,13 +12,17 @@ import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } fro
12
12
  // capability derived from them can appear mid-session (e.g. the first mouse
13
13
  // move) but never flickers away.
14
14
  //
15
- // `ownedWrite: true` on the signals below: each is lazily created the first
16
- // time its ensure* function runs, which can happen inside a tracked scope
17
- // (e.g. a memo's first read of env.inputDevices). Sticky events replay their
18
- // cached value synchronously on subscribe (srt:events' on()), so that same
19
- // call can immediately write the signal it just created. That's a legitimate
20
- // internal-state write, not a stray write escaping a computation, so it opts
21
- // out of the write-in-owned-scope guard.
15
+ // Every fact below is lazily created the first time its ensure* function
16
+ // runs, which is usually inside a component or a computation (a memo's first
17
+ // read of env.inputDevices, a HUD effect's first read of pointerLocked()).
18
+ // Created there, the fact would belong to that reader: sticky events replay
19
+ // their cached value synchronously on subscribe (srt:events' on()), so the
20
+ // on() call writes the signal it just created inside the reader's owned
21
+ // scope - a render-time write, which the guard rejects - and a root or a
22
+ // subscription created there is disposed with the reader. The facts are
23
+ // app-lifetime, so each ensure* creates under no owner (runWithOwner(null)):
24
+ // the replay lands unowned, nothing is filed under the reader, and no
25
+ // `ownedWrite` opt-out is needed.
22
26
 
23
27
  /** Connected input device classes, as reported by the runtime. */
24
28
  export interface InputDevices {
@@ -39,58 +43,68 @@ let devicesAccessor: (() => InputDevices | undefined) | undefined
39
43
 
40
44
  function ensureDevicesState() {
41
45
  if (devicesAccessor) return
42
- let [devices, setDevices] = createSignal<InputDevices | undefined>(undefined, { ownedWrite: true })
43
- // Sticky: the current state replays on subscribe, so the first read already
44
- // sees it on runtimes that report devices.
45
- on("inputDevices", (d: InputDevices) => {
46
- setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch, screenKeyboard: !!d.screenKeyboard })
46
+ runWithOwner(null, () => {
47
+ let [devices, setDevices] = createSignal<InputDevices | undefined>(undefined)
48
+ // Sticky: the current state replays on subscribe, so the first read already
49
+ // sees it on runtimes that report devices.
50
+ on("inputDevices", (d: InputDevices) => {
51
+ setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch, screenKeyboard: !!d.screenKeyboard })
52
+ })
53
+ devicesAccessor = devices
47
54
  })
48
- devicesAccessor = devices
49
55
  }
50
56
 
51
57
  let systemThemeAccessor: (() => SystemTheme) | undefined
52
58
 
53
59
  function ensureSystemThemeState() {
54
60
  if (systemThemeAccessor) return
55
- let [theme, setTheme] = createSignal<SystemTheme>("unknown", { ownedWrite: true })
56
- on("systemTheme", (e: { theme?: SystemTheme }) => setTheme(e.theme ?? "unknown"))
57
- systemThemeAccessor = theme
61
+ runWithOwner(null, () => {
62
+ let [theme, setTheme] = createSignal<SystemTheme>("unknown")
63
+ on("systemTheme", (e: { theme?: SystemTheme }) => setTheme(e.theme ?? "unknown"))
64
+ systemThemeAccessor = theme
65
+ })
58
66
  }
59
67
 
60
68
  let visibilityAccessor: (() => Visibility) | undefined
61
69
 
62
70
  function ensureVisibilityState() {
63
71
  if (visibilityAccessor) return
64
- let [visibility, setVisibility] = createSignal<Visibility>("visible", { ownedWrite: true })
65
- // Sticky. The runtime may report the same state through several platform
66
- // paths; the signal's equality check turns repeats into no-ops for
67
- // reactive consumers.
68
- on("visibility", (e: { state?: Visibility }) => setVisibility(e.state === "hidden" ? "hidden" : "visible"))
69
- visibilityAccessor = visibility
72
+ runWithOwner(null, () => {
73
+ let [visibility, setVisibility] = createSignal<Visibility>("visible")
74
+ // Sticky. The runtime may report the same state through several platform
75
+ // paths; the signal's equality check turns repeats into no-ops for
76
+ // reactive consumers.
77
+ on("visibility", (e: { state?: Visibility }) => setVisibility(e.state === "hidden" ? "hidden" : "visible"))
78
+ visibilityAccessor = visibility
79
+ })
70
80
  }
71
81
 
72
82
  let orientationAccessor: (() => Orientation) | undefined
73
83
 
74
84
  function ensureOrientationState() {
75
85
  if (orientationAccessor) return
76
- let [orientation, setOrientation] = createSignal<Orientation>("unknown", { ownedWrite: true })
77
- on("displayOrientation", (e: { orientation?: Orientation }) => {
78
- setOrientation(e.orientation ?? "unknown")
86
+ runWithOwner(null, () => {
87
+ let [orientation, setOrientation] = createSignal<Orientation>("unknown")
88
+ on("displayOrientation", (e: { orientation?: Orientation }) => {
89
+ setOrientation(e.orientation ?? "unknown")
90
+ })
91
+ orientationAccessor = orientation
79
92
  })
80
- orientationAccessor = orientation
81
93
  }
82
94
 
83
95
  let textScaleAccessor: (() => number) | undefined
84
96
 
85
97
  function ensureTextScaleState() {
86
98
  if (textScaleAccessor) return
87
- let [scale, setScale] = createSignal(1, { ownedWrite: true })
88
- // Sticky, like systemTheme. Guard nonsense values: a runtime bug reporting
89
- // 0 or a negative would otherwise collapse all text.
90
- on("textScale", (e: { scale?: number }) => {
91
- setScale(typeof e.scale === "number" && e.scale > 0 ? e.scale : 1)
99
+ runWithOwner(null, () => {
100
+ let [scale, setScale] = createSignal(1)
101
+ // Sticky, like systemTheme. Guard nonsense values: a runtime bug reporting
102
+ // 0 or a negative would otherwise collapse all text.
103
+ on("textScale", (e: { scale?: number }) => {
104
+ setScale(typeof e.scale === "number" && e.scale > 0 ? e.scale : 1)
105
+ })
106
+ textScaleAccessor = scale
92
107
  })
93
- textScaleAccessor = scale
94
108
  }
95
109
 
96
110
  let mouseSeenAccessor: (() => boolean) | undefined
@@ -98,50 +112,54 @@ let touchSeenAccessor: (() => boolean) | undefined
98
112
 
99
113
  function ensurePointerState() {
100
114
  if (mouseSeenAccessor) return
101
- let [mouse, setMouse] = createSignal(false)
102
- let [touch, setTouch] = createSignal(false)
103
- let sawMouse = false
104
- let sawTouch = false
105
- let unsubs: (() => void)[] = []
106
- let unsubMove: () => void = null!
107
- let note = (e: { pointerType?: string }) => {
108
- if (e.pointerType === "mouse" && !sawMouse) {
109
- sawMouse = true
110
- setMouse(true)
111
- // Moves have nothing left to teach: touch is learned from downs (a
112
- // touch never moves without one), so drop the move subscription and
113
- // with it the ambient interest bit that forces move deliveries.
114
- unsubMove()
115
- } else if (e.pointerType === "touch" && !sawTouch) {
116
- sawTouch = true
117
- setTouch(true)
115
+ runWithOwner(null, () => {
116
+ let [mouse, setMouse] = createSignal(false)
117
+ let [touch, setTouch] = createSignal(false)
118
+ let sawMouse = false
119
+ let sawTouch = false
120
+ let unsubs: (() => void)[] = []
121
+ let unsubMove: () => void = null!
122
+ let note = (e: { pointerType?: string }) => {
123
+ if (e.pointerType === "mouse" && !sawMouse) {
124
+ sawMouse = true
125
+ setMouse(true)
126
+ // Moves have nothing left to teach: touch is learned from downs (a
127
+ // touch never moves without one), so drop the move subscription and
128
+ // with it the ambient interest bit that forces move deliveries.
129
+ unsubMove()
130
+ } else if (e.pointerType === "touch" && !sawTouch) {
131
+ sawTouch = true
132
+ setTouch(true)
133
+ }
134
+ // Both types observed: nothing left to learn, stop listening.
135
+ if (sawMouse && sawTouch) for (let u of unsubs) u()
118
136
  }
119
- // Both types observed: nothing left to learn, stop listening.
120
- if (sawMouse && sawTouch) for (let u of unsubs) u()
121
- }
122
- // Downs always deliver, so the raw bus tap suffices; moves are gated when
123
- // nobody listens, so they go through onPointerMove, whose subscription
124
- // keeps them flowing. The probe is app-lifetime: createRoot detaches its
125
- // scope cleanup from whatever computation first read the accessor.
126
- unsubMove = createRoot(() => onPointerMove(note))
127
- unsubs.push(unsubMove, on("pointerDown", note))
128
- mouseSeenAccessor = mouse
129
- touchSeenAccessor = touch
137
+ // Downs always deliver, so the raw bus tap suffices; moves are gated when
138
+ // nobody listens, so they go through onPointerMove, whose subscription
139
+ // keeps them flowing. Unowned here, so it registers no cleanup: the
140
+ // returned unsubscribe is the probe's only handle, as intended.
141
+ unsubMove = onPointerMove(note)
142
+ unsubs.push(unsubMove, on("pointerDown", note))
143
+ mouseSeenAccessor = mouse
144
+ touchSeenAccessor = touch
145
+ })
130
146
  }
131
147
 
132
148
  let keyboardSeenAccessor: (() => boolean) | undefined
133
149
 
134
150
  function ensureKeyboardState() {
135
151
  if (keyboardSeenAccessor) return
136
- let [keyboard, setKeyboard] = createSignal(false)
137
- // Soft keyboards also deliver some keydowns (Backspace, Return), so this can
138
- // read true on a touch-only device once the user types in a field. Only a
139
- // fallback: capabilities prefer runtime-reported device presence.
140
- let unsub = on("keydown", () => {
141
- setKeyboard(true)
142
- unsub()
152
+ runWithOwner(null, () => {
153
+ let [keyboard, setKeyboard] = createSignal(false)
154
+ // Soft keyboards also deliver some keydowns (Backspace, Return), so this can
155
+ // read true on a touch-only device once the user types in a field. Only a
156
+ // fallback: capabilities prefer runtime-reported device presence.
157
+ let unsub = on("keydown", () => {
158
+ setKeyboard(true)
159
+ unsub()
160
+ })
161
+ keyboardSeenAccessor = keyboard
143
162
  })
144
- keyboardSeenAccessor = keyboard
145
163
  }
146
164
 
147
165
  /**
package/src/gamepad.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal } from "@solidjs/signals"
1
+ import { createSignal, runWithOwner } from "@solidjs/signals"
2
2
  import { on } from "srt:events"
3
3
 
4
4
  // Gamepad State: a reactive mirror of the runtime's sticky "gamepads" event.
@@ -18,7 +18,7 @@ import { on } from "srt:events"
18
18
  *
19
19
  * The snapshot is a faithful report. Note that pressing "back" (select) on a
20
20
  * mapped pad ALSO emits the `back` event (see onBack) - it is the pad-side
21
- * sibling of Android's system back, the runtime's exit-to-launcher gesture.
21
+ * sibling of Android's system back, the runtime's exit-to-player gesture.
22
22
  * Apps that bind "back" for their own controls should preventDefault that
23
23
  * event.
24
24
  */
@@ -52,11 +52,13 @@ let gamepadsAccessor: (() => (GamepadState | null)[]) | undefined
52
52
  */
53
53
  export function gamepads(): (GamepadState | null)[] {
54
54
  if (!gamepadsAccessor) {
55
- // ownedWrite: the sticky event replays synchronously inside on(), which
56
- // may run within a tracked scope's first read (see environment.ts).
57
- let [pads, setPads] = createSignal<(GamepadState | null)[]>([], { ownedWrite: true })
58
- on("gamepads", (e: { pads?: (GamepadState | null)[] }) => setPads(e.pads ?? []))
59
- gamepadsAccessor = pads
55
+ // Under no owner: the sticky event replays synchronously inside on(),
56
+ // and the first read is usually a tracked scope (see environment.ts).
57
+ runWithOwner(null, () => {
58
+ let [pads, setPads] = createSignal<(GamepadState | null)[]>([])
59
+ on("gamepads", (e: { pads?: (GamepadState | null)[] }) => setPads(e.pads ?? []))
60
+ gamepadsAccessor = pads
61
+ })
60
62
  }
61
- return gamepadsAccessor()
63
+ return gamepadsAccessor!()
62
64
  }
package/src/gpu.ts CHANGED
@@ -51,7 +51,11 @@
51
51
  // and target holds 8-bit RGBA UNORM exactly as written; nothing converts to
52
52
  // or from linear light. `filter: "linear"` averages and the `blend` modes
53
53
  // accumulate non-linear values - the usual approximation, stated so
54
- // shaders written today stay correct if a format vocabulary arrives.
54
+ // shaders written today stay correct. The one exception is opt-in: an
55
+ // "rgba8-srgb" texture decodes to linear light when a shader samples it
56
+ // (and "rgba16f" holds linear HDR values), which is how a linear-space
57
+ // renderer such as `@solidrt/3d` reads its inputs before encoding its
58
+ // output back into this contract.
55
59
 
56
60
  import { createEffect, createSignal, getOwner, onCleanup, untrack } from "@solidjs/signals"
57
61
  import * as gpu from "flux:gpu"
@@ -73,9 +77,16 @@ export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode; mip
73
77
  export type { FilterMode, WrapMode, TextureBinding, TextureBindings } from "flux:gpu"
74
78
 
75
79
  // Pixel format option for the pixel-upload creates (createTexture,
76
- // createMutableTexture), fixed for the id's lifetime like the sampler state.
77
- // "rgba8" (default) or "r8" - see TextureFormat in flux:gpu for the r8
78
- // contract (1 byte/pixel, sampled as `(v, 0, 0, 1)`, any width).
80
+ // createCubeTexture, createMutableTexture), fixed for the id's lifetime like
81
+ // the sampler state. "rgba8" (default), "rgba8-srgb" (decodes to linear
82
+ // light on sample), "r8", the float data-texture formats "r32f"/"rgba32f"
83
+ // (Float32Array payload, nearest/texelFetch sampling only, no readback) and
84
+ // the HDR image format "rgba16f" (Float32Array payload packed to half float,
85
+ // filterable) - see TextureFormat in flux:gpu for each format's contract.
86
+ // Draw targets (createDrawTarget, createCubeDrawTarget) take the renderable
87
+ // subset: "rgba8", "rgba8-srgb", "rgba16f".
88
+ // "etc2-rgba8" (compressed) is a reserved future value of the same
89
+ // vocabulary.
79
90
  export type TextureFormatOptions = { format?: gpu.TextureFormat }
80
91
  export type { TextureFormat } from "flux:gpu"
81
92
 
@@ -136,7 +147,7 @@ export {
136
147
  // GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
137
148
  // ping-pong buffer, reset state to a known image.
138
149
  export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
139
- export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceAttribute, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
150
+ export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceAttribute, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
140
151
 
141
152
  // The draw-list verbs, re-exported raw: entries live and die with their draw
142
153
  // target (see createDrawTarget below), so there is no per-entry lifetime to
@@ -150,7 +161,10 @@ export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexF
150
161
  // params every entry reads. setDrawOrder replaces the whole
151
162
  // list order with a full permutation of the live ids - the sorting verb
152
163
  // (opaque front-to-back, transparent back-to-front, re-issued when the
153
- // camera moves).
164
+ // camera moves). That orders ENTRIES; ordering the instance RECORDS within
165
+ // one entry is the instanceOrder creation option (see InstanceOrder), whose
166
+ // projected-key direction updates ride setDraw/setDrawRange as
167
+ // orderDirection.
154
168
  export { addDraw, removeDraw, setDrawBuffers, setDrawOrder, setDrawParams, setDrawRange, setDrawTextures } from "flux:gpu"
155
169
 
156
170
  // The device ceilings (max texture/target size, sampler inputs per pass,
@@ -217,8 +231,9 @@ export { captureSnapshot, readTexture } from "flux:gpu"
217
231
  /**
218
232
  * Uploads raw pixels to an immutable GPU texture and returns its id (use it
219
233
  * as `<texture src={id} />`). `data` must be exactly `width * height` pixels
220
- * at the declared format's size (`* 4` bytes for the default "rgba8", `* 1`
221
- * for "r8"); a mismatch throws. RGBA data is uploaded verbatim and composited
234
+ * at the declared format's size, in the view type matching the format
235
+ * (Uint8Array for "rgba8"/"rgba8-srgb"/"r8", Float32Array for "r32f"/
236
+ * "rgba32f"/"rgba16f"); a mismatch throws. RGBA data is uploaded verbatim and composited
222
237
  * as premultiplied alpha, like every texture: `decodeImage` and the readback
223
238
  * calls already deliver that, and hand-built pixels must too (`rgb * a`). For pixels you intend to mutate and
224
239
  * re-upload, use `createMutableTexture` instead. When called inside a
@@ -230,7 +245,7 @@ export { captureSnapshot, readTexture } from "flux:gpu"
230
245
  * scope.
231
246
  */
232
247
  export function createTexture(
233
- data: Uint8Array,
248
+ data: Uint8Array | Float32Array,
234
249
  width: number,
235
250
  height: number,
236
251
  opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
@@ -240,18 +255,52 @@ export function createTexture(
240
255
  return id
241
256
  }
242
257
 
258
+ /**
259
+ * Uploads a cube map: six square faces of `size * size` pixels in GL order
260
+ * (+X, -X, +Y, -Y, +Z, -Z), each in the view type matching the declared
261
+ * format like `createTexture`, and returns its id. A cube map is sampled by
262
+ * DIRECTION from a `uniform samplerCube` in a shader (`texture(uEnv, dir)`;
263
+ * `textureLod` for an explicit mip level, `mipmap: true` builds the chain
264
+ * from the faces) - the skybox, reflection and environment-lighting
265
+ * primitive. `faces` may instead be an explicit mip chain - an array of
266
+ * six-face arrays, level 0 first, halving down to the 1x1 level (the full
267
+ * chain) - uploaded as given: prefiltered environment levels, with no
268
+ * generated chain and so no renderability requirement on the format (an
269
+ * "rgba16f" chain works on every device). Explicit levels imply `mipmap:
270
+ * true`. Sampler-only: the id binds through `textures` like any other,
271
+ * but `<texture src>` cannot display it and `readTexture`, `copyTexture`,
272
+ * `uploadTexture` and `resizeTexture` throw (it is create-once). `wrap` has
273
+ * no effect - cube filtering is seamless across faces. Binding a cube map
274
+ * to a `sampler2D`, or a 2D texture to a `samplerCube`, throws at the bind.
275
+ * Note GL's cube convention: each face is what a lookup in that direction
276
+ * returns, the cube as seen from OUTSIDE, so a face set authored the Three
277
+ * way (each face as seen from inside) reads mirrored on x unless each
278
+ * image is mirrored at load. Freed like `createTexture` (auto-free under a
279
+ * reactive owner, `{ autoFree: false }` or `destroyTexture` otherwise).
280
+ */
281
+ export function createCubeTexture(
282
+ faces: (Uint8Array | Float32Array)[] | (Uint8Array | Float32Array)[][],
283
+ size: number,
284
+ opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
285
+ ): gpu.TextureId {
286
+ let id = gpu.createCubeTexture(faces, size, opts)
287
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
288
+ return id
289
+ }
290
+
243
291
  /**
244
292
  * Creates a GPU texture you intend to update over time: seed it with `data`,
245
293
  * then call `uploadTexture(id, data)` (from flux:gpu) to push new pixels.
246
294
  * `data` must hold at least `width * height` pixels at the declared format's
247
- * size (`* 4` bytes for the default "rgba8", `* 1` for "r8"; it may hold
248
- * several frames). Like `createTexture`, the texture is freed automatically
295
+ * size, in the view type matching the format (Uint8Array for "rgba8"/
296
+ * "rgba8-srgb"/"r8", Float32Array for "r32f"/"rgba32f"/"rgba16f"); it may
297
+ * hold several frames. Like `createTexture`, the texture is freed automatically
249
298
  * when the reactive owner is disposed (opt out with `{ autoFree: false }`);
250
299
  * created outside a reactive scope you must call `destroyTexture` (from
251
300
  * flux:gpu) yourself.
252
301
  */
253
302
  export function createMutableTexture(
254
- data: Uint8Array,
303
+ data: Uint8Array | Float32Array,
255
304
  width: number,
256
305
  height: number,
257
306
  opts?: CreateOptions & SamplerOptions & TextureFormatOptions,
@@ -360,6 +409,8 @@ export function createShaderTarget(
360
409
  /** One buffer per instance slot of the pipeline (index = the
361
410
  * attributes' `slot`); pass this OR `instanceBuffer`, not both. */
362
411
  instanceBuffers?: gpu.BufferId[]
412
+ /** Draw the instance records in key order (see InstanceOrder). */
413
+ instanceOrder?: gpu.InstanceOrder
363
414
  clearColor?: [number, number, number, number]
364
415
  render?: "auto" | "manual"
365
416
  loadOp?: "clear" | "load"
@@ -400,6 +451,13 @@ export function createShaderTarget(
400
451
  * driven later with `setTargetTextures`, same precedence and coverage
401
452
  * rules.
402
453
  *
454
+ * `format` is the color storage: "rgba8" (default), "rgba8-srgb" (encodes
455
+ * what a pass writes, decodes on sample) or "rgba16f" (half float, keeps
456
+ * the range - an HDR view or bake; where `limits.halfFloatRenderable`,
457
+ * else it throws). A target of either non-default format is SAMPLER-ONLY:
458
+ * a pass source, never displayed by a `<texture>` leaf, read back or
459
+ * copied (render it through a pass into an rgba8 target for those).
460
+ *
403
461
  * `into` makes a sub-target: a draw target rendering into the rectangle at
404
462
  * `x`/`y` (top-left origin) of draw target `into`'s storage, so N views or
405
463
  * N shadow maps share one texture and ONE pass; the id is a draw target to
@@ -421,6 +479,7 @@ export function createDrawTarget(
421
479
  params?: gpu.ShaderParams | null,
422
480
  opts?: {
423
481
  depth?: boolean | "texture"
482
+ format?: "rgba8" | "rgba8-srgb" | "rgba16f"
424
483
  textures?: gpu.TextureBindings
425
484
  clearColor?: [number, number, number, number]
426
485
  render?: "auto" | "manual"
@@ -437,6 +496,41 @@ export function createDrawTarget(
437
496
  return id
438
497
  }
439
498
 
499
+ /**
500
+ * Creates a cube draw target: a draw target whose output is a `size` x
501
+ * `size` cube map, rendered one face at a time with `renderTarget(id,
502
+ * face)` (0 +X, 1 -X, 2 +Y, 3 -Y, 4 +Z, 5 -Z) - the reflection-probe
503
+ * primitive (`@solidrt/3d`'s `scene.createReflectionProbe` drives it).
504
+ * Manual by contract, one depth renderbuffer for the six faces (`depth:
505
+ * true`), no samples or tiles; `format` rgba8, rgba8-srgb (encodes on
506
+ * write, decodes on sample) or rgba16f (half float, the HDR probe format,
507
+ * where `limits.halfFloatRenderable`); `mipmap: true` allocates the chain and
508
+ * `renderTarget(id, face, level)` renders one level of it (a face render
509
+ * without a level regenerates the chain instead) - the prefiltered
510
+ * environment path. The id binds to a `samplerCube`
511
+ * and cannot be displayed, read back, copied or resized. Each face pass
512
+ * inverts the front-face rule (a GL cube face is the x mirror of a 2D
513
+ * target's image), so render the faces through an x-mirrored projection;
514
+ * see flux:gpu's createCubeDrawTarget. Freed like every target.
515
+ */
516
+ export function createCubeDrawTarget(
517
+ size: number,
518
+ params?: gpu.ShaderParams | null,
519
+ opts?: {
520
+ depth?: boolean
521
+ format?: "rgba8" | "rgba8-srgb" | "rgba16f"
522
+ textures?: gpu.TextureBindings
523
+ clearColor?: [number, number, number, number]
524
+ render?: "manual"
525
+ loadOp?: "clear" | "load"
526
+ } & CreateOptions &
527
+ SamplerOptions,
528
+ ): gpu.TextureId {
529
+ let id = gpu.createCubeDrawTarget(size, params, opts)
530
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
531
+ return id
532
+ }
533
+
440
534
  /** The reactive shader description `createShaderTextureMemo` builds from.
441
535
  * Sampling (`filter`/`wrap`/`mipmap`) is creation-time state, so changing it rebuilds
442
536
  * at a fresh id, like a fragment-source or sampler-binding change. */
@@ -449,14 +543,22 @@ export type ShaderSpec = {
449
543
  } & SamplerOptions
450
544
 
451
545
  // Shallow name->value equality for params/textures records; treats undefined
452
- // as the empty record. A param value may be a number or a flat number array
453
- // (typed uniforms), so arrays compare elementwise; a texture binding may be
454
- // an `{ id, filter?, wrap? }` override, compared field by field.
455
- type RecordValue = number | number[] | gpu.TextureBinding
546
+ // as the empty record. A param value may be a number or a flat number list
547
+ // (typed uniforms: a number[] or a Float32Array/Float64Array), so lists
548
+ // compare elementwise; a texture binding may be an `{ id, filter?, wrap? }`
549
+ // override, compared field by field.
550
+ type RecordValue = number | number[] | Float32Array | Float64Array | gpu.TextureBinding
551
+ function isNumberList(v: RecordValue | undefined): v is number[] | Float32Array | Float64Array {
552
+ return Array.isArray(v) || v instanceof Float32Array || v instanceof Float64Array
553
+ }
456
554
  function sameValue(a: RecordValue | undefined, b: RecordValue | undefined): boolean {
457
555
  if (a === b) return true
458
- if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => v === b[i])
459
- if (typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
556
+ if (isNumberList(a) && isNumberList(b)) {
557
+ if (a.length !== b.length) return false
558
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
559
+ return true
560
+ }
561
+ if (typeof a === "object" && typeof b === "object" && !isNumberList(a) && !isNumberList(b)) {
460
562
  return a.id === b.id && a.filter === b.filter && a.wrap === b.wrap
461
563
  }
462
564
  return false
@@ -606,6 +708,8 @@ export function createPipelineTexture(
606
708
  /** One buffer per instance slot (index = the attributes' `slot`);
607
709
  * pass this OR `instanceBuffer`, not both. */
608
710
  instanceBuffers?: gpu.BufferId[]
711
+ /** Draw the instance records in key order (see InstanceOrder). */
712
+ instanceOrder?: gpu.InstanceOrder
609
713
  topology?: gpu.Topology
610
714
  depth?: boolean
611
715
  depthWrite?: boolean
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./renderer"
2
- export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, snapshotTexture, onPointerMove } from "./core"
2
+ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, getLayoutBox, snapshotTexture, onPointerMove } from "./core"
3
3
  export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
4
4
  export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
@@ -12,7 +12,7 @@ export { gamepads } from "./gamepad"
12
12
  export type { GamepadState } from "./gamepad"
13
13
  export { capabilities } from "./capabilities"
14
14
  export type { Capabilities, WindowSizeClass } from "./capabilities"
15
- export { createTexture } from "./gpu"
15
+ export { createCubeTexture, createTexture } from "./gpu"
16
16
  export type { TextureId } from "./gpu"
17
17
  export { createImage, decodeImage, encodeImage } from "./image"
18
18
  export type { DecodedImage, ImageSource } from "./image"
package/src/pan.ts CHANGED
@@ -18,7 +18,13 @@ export interface PanOptions {
18
18
  */
19
19
  axis?: PanAxis
20
20
  onPanStart?: () => void
21
- /** Pointer movement since the previous event; positive dx is rightward, positive dy downward. */
21
+ /**
22
+ * Pointer movement since the previous event, in the handler node's PARENT
23
+ * frame (the frame its own x/y live in: window pixels under no scaling
24
+ * ancestor, design pixels under a designSize fit), so it applies 1:1
25
+ * whether the consumer moves the node itself or content inside it.
26
+ * Positive dx is rightward, positive dy downward.
27
+ */
22
28
  onPanMove?: (dx: number, dy: number) => void
23
29
  onPanEnd?: () => void
24
30
  }
@@ -35,8 +41,17 @@ export interface PanOptions {
35
41
  // cancel() is the external-cancel hook; it ends an active pan without
36
42
  // onPanEnd. Options are read at event time. Single-pointer by design; for
37
43
  // multi-pointer pinch/rotate compose createTransform instead.
44
+ //
45
+ // Frames: the slop is finger travel, so it is measured in window pixels
46
+ // (clientX/clientY); the deltas are measured in the node's parent frame
47
+ // (parentX/parentY), the frame the node's own coordinates live in, so a
48
+ // scaled ancestor (a designSize fit) is accounted for and both the drag
49
+ // idiom (move the node) and the content idiom (scroll what is inside it)
50
+ // apply the delta unchanged. The event carries both frames exactly, on
51
+ // and off the node.
38
52
  export function createPan(options: PanOptions) {
39
- // Down position while armed; last delivered position while active.
53
+ // Down position (window pixels) while armed; last delivered position
54
+ // (parent frame) while active.
40
55
  let origin: { x: number; y: number } | null = null
41
56
  let active: number | null = null
42
57
  let armed: number | null = null
@@ -78,7 +93,7 @@ export function createPan(options: PanOptions) {
78
93
  if (arena.steal(e.pointerId, owner)) {
79
94
  active = e.pointerId
80
95
  armed = null
81
- origin = { x: e.clientX, y: e.clientY }
96
+ origin = { x: e.parentX, y: e.parentY }
82
97
  options.onPanStart?.()
83
98
  } else {
84
99
  // The arena is resolved against us; the drag belongs elsewhere.
@@ -87,8 +102,8 @@ export function createPan(options: PanOptions) {
87
102
  return
88
103
  }
89
104
  if (active === e.pointerId && origin) {
90
- options.onPanMove?.(e.clientX - origin.x, e.clientY - origin.y)
91
- origin = { x: e.clientX, y: e.clientY }
105
+ options.onPanMove?.(e.parentX - origin.x, e.parentY - origin.y)
106
+ origin = { x: e.parentX, y: e.parentY }
92
107
  }
93
108
  },
94
109
  onPointerUp: (e: PointerEvent) => {
@@ -66,8 +66,8 @@ declare module "srt:events" {
66
66
  // The running application's own surface (lattice), present in every build.
67
67
  declare module "srt:app" {
68
68
  /**
69
- * Leave the current app, unconditionally: back to the launcher in a dev
70
- * client, quit when standalone or at the launcher root (on Android the
69
+ * Leave the current app, unconditionally: back to the player in a dev
70
+ * client, quit when standalone or at the player root (on Android the
71
71
  * client backgrounds instead of dying). The default action of an
72
72
  * unprevented `back` event; prefer the @solidrt/core re-export.
73
73
  */
@@ -81,7 +81,7 @@ declare module "srt:dev" {
81
81
  export const canDiscover: boolean
82
82
  export const recents: string[]
83
83
  /**
84
- * The dev-server address the client was launched with (so the launcher can
84
+ * The dev-server address the client was launched with (so the player can
85
85
  * auto-connect without on-device interaction), or null when launched without
86
86
  * one.
87
87
  */
@@ -101,7 +101,7 @@ declare module "srt:dev" {
101
101
  export function registerDebug(name: string, fn: (args?: any) => unknown): void
102
102
  }
103
103
 
104
- // Installed-app management (lattice), the launcher's surface over the client's
104
+ // Installed-app management (lattice), the player's surface over the client's
105
105
  // version store. Present only in go/dev client builds; elsewhere `available`
106
106
  // is false, `list` returns [], and launch/remove are no-ops.
107
107
  declare module "srt:apps" {
@@ -166,7 +166,7 @@ declare module "srt:apps" {
166
166
  export function info(id: string): AppInfo
167
167
  /**
168
168
  * Boot the app's installed current version, replacing the running app (the
169
- * launcher). Throws when the app is not installed. Custom fonts of the
169
+ * player). Throws when the app is not installed. Custom fonts of the
170
170
  * launched app register at client startup only, not mid-session.
171
171
  */
172
172
  export function launch(id: string): void
@@ -182,8 +182,8 @@ declare module "srt:apps" {
182
182
  */
183
183
  export function clearCache(id: string): void
184
184
  /**
185
- * Build identity of this runtime, for the launcher's settings screen. Not
186
- * app-specific, but surfaced here since the launcher already imports this
185
+ * Build identity of this runtime, for the player's settings screen. Not
186
+ * app-specific, but surfaced here since the player already imports this
187
187
  * module. `version` is the release version (git describe; "0.0.0-dev" in a
188
188
  * plain build), `profile` is "debug" or "release", `platform` is the OS
189
189
  * (std::env::consts::OS, e.g. "linux", "android", "windows", "macos").