@solidrt/core 0.0.70 → 0.0.72
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/AGENTS.md +14 -3
- package/agents/performance.md +13 -2
- package/docs/reference/input.md +1 -1
- package/examples/README.md +1 -0
- package/examples/gpu-shader-chain.tsx +135 -0
- package/package.json +3 -3
- package/src/core.ts +11 -8
- package/src/gpu.ts +129 -0
- package/src/index.ts +20 -2
- package/src/renderer.ts +41 -2
- package/src/types.d.ts +22 -11
- package/src/vertex.ts +294 -0
- package/src/window.ts +55 -24
package/AGENTS.md
CHANGED
|
@@ -16,6 +16,11 @@ matches the work before starting it:
|
|
|
16
16
|
before writing any per-frame code, any animation, or anything that writes
|
|
17
17
|
properties in a loop.
|
|
18
18
|
|
|
19
|
+
When SolidRT itself gets in the way (a bug, a missing export, a doc that
|
|
20
|
+
misled you, a trap nothing warned about), append it to SOLIDRT-FEEDBACK.md
|
|
21
|
+
in the project root, creating the file if needed: what you hit, and what
|
|
22
|
+
you expected instead.
|
|
23
|
+
|
|
19
24
|
## The window is device-sized - design fluid
|
|
20
25
|
|
|
21
26
|
A SolidRT window is host-sized and resizable, and the SAME app runs on phones,
|
|
@@ -304,7 +309,10 @@ that reads exactly like Solid fallout.
|
|
|
304
309
|
|
|
305
310
|
- Events: there is NO `onClick`/`onPress`. A "button" is a `<view>`/`<rect>`
|
|
306
311
|
with `onPointerDown`. Handlers: onPointerDown/Up/Move/Enter/Leave, onWheel,
|
|
307
|
-
onKeyDown/Up, onTextInput, onFocus/onBlur.
|
|
312
|
+
onKeyDown/Up, onTextInput, onFocus/onBlur. `pointerEvents` gates hit
|
|
313
|
+
testing and cascades like CSS: "none" on a container also disables its
|
|
314
|
+
subtree until a descendant sets "auto" (or "all", which makes the element
|
|
315
|
+
one target and skips its children). Text entry: focus a node with an
|
|
308
316
|
`onTextInput` handler. Focus alone never raises the on-screen keyboard: a
|
|
309
317
|
tap on the focused node (or explicit startTextInput()) does, and never
|
|
310
318
|
while a physical keyboard is attached; on keyboard-equipped platforms the
|
|
@@ -617,8 +625,11 @@ that reads exactly like Solid fallout.
|
|
|
617
625
|
update: it runs after every onFrame callback and the reactive flush,
|
|
618
626
|
when the frame's state is final, for what must see everything the frame
|
|
619
627
|
wrote and write once more (a camera follow, a fit to a measured box;
|
|
620
|
-
Unity's LateUpdate)
|
|
621
|
-
|
|
628
|
+
Unity's LateUpdate) - native motion (a clip player, a node transition)
|
|
629
|
+
steps before the frame's callbacks and starts what they declared after
|
|
630
|
+
them, so a follow of an animated node reads the pose this frame draws;
|
|
631
|
+
its signal writes are flushed before the paint too, the way onLayout's
|
|
632
|
+
are. `onBeforeRender(fn, {
|
|
622
633
|
publish: true })` runs after those, and again after a frame's onLayout
|
|
623
634
|
handlers: the pass where an extension hands the engine what the frame
|
|
624
635
|
wrote into its mirrors (a scene its record buffers and lights, a sprite
|
package/agents/performance.md
CHANGED
|
@@ -24,7 +24,13 @@ Rules, in order of leverage:
|
|
|
24
24
|
use them for anything interactive), `{ duration, curve }` is a tween
|
|
25
25
|
(`linear | ease | ease-in | ease-out | ease-in-out` or a cubic-bezier
|
|
26
26
|
array; tweens restart from the current value on retarget, CSS
|
|
27
|
-
semantics). Keys are property names
|
|
27
|
+
semantics). Keys are the animatable property names (the
|
|
28
|
+
TransitionPropName set: opacity, transform components, color, shadow,
|
|
29
|
+
and x/y/w/h on the detached primitives), `all` as catch-all, and
|
|
30
|
+
`layout`: a layout property (width, height, padding, flex, order)
|
|
31
|
+
never takes an entry of its own; whatever layout does to the
|
|
32
|
+
element's box animates through `layout`, which slides it from its
|
|
33
|
+
painted box to its new one. A string
|
|
28
34
|
is shorthand (`transition="300ms ease-out"`); `delay` holds each
|
|
29
35
|
write, `from` animates the first attach in (enter), `exit` animates
|
|
30
36
|
removal out before the node frees, `stagger` on a parent cascades its
|
|
@@ -102,7 +108,12 @@ Rules, in order of leverage:
|
|
|
102
108
|
boundary node itself (or any ancestor) are hoisted out of the cache and
|
|
103
109
|
applied at composite time, so animating x/y/scale/rotate/opacity of a
|
|
104
110
|
boundary does NOT re-raster it (verified by A/B measurement - the damage
|
|
105
|
-
system classifies these as
|
|
111
|
+
system classifies these as Compose and keeps the node's own cache). One
|
|
112
|
+
exception, for crispness: a boundary holding text that comes to rest at
|
|
113
|
+
a new scale re-records once there (a snapshot boundary re-rasterizes
|
|
114
|
+
once), so its content is rasterized at the resting size instead of
|
|
115
|
+
stretched, and a scale transition with a known end rasters for that end
|
|
116
|
+
as it starts; a press scale that returns to 1 never re-rasters.
|
|
106
117
|
What DOES invalidate the cache is any paint or content change inside the
|
|
107
118
|
subtree - colors, path data, text, a Show toggling - so drive animation
|
|
108
119
|
with transforms and keep the cached content itself static. Off a boundary,
|
package/docs/reference/input.md
CHANGED
|
@@ -8,7 +8,7 @@ the walk.
|
|
|
8
8
|
|
|
9
9
|
## Handlers
|
|
10
10
|
|
|
11
|
-
{{ decl packages/core/src/types.d.ts
|
|
11
|
+
{{ decl packages/core/src/types.d.ts InputProps }}
|
|
12
12
|
|
|
13
13
|
`pointerEvents="none"` takes an element out of hit testing, and the walk skips
|
|
14
14
|
it as an ancestor too, so the `parentX`/`parentY` of an event stay in the frame
|
package/examples/README.md
CHANGED
|
@@ -51,6 +51,7 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
|
|
|
51
51
|
- `gpu-texture-blend.tsx` - compositing two shader targets as stacked `<texture>` layers with `blendMode` ("plus" additive over a base pass), the alternative to a third shader that samples both. Click toggles against `"source-over"` to show why. The tree-level counterpart to blending within one draw (`gpu-particles.tsx`); without `blend: "add"` a target's own draw runs with GL blending disabled.
|
|
52
52
|
- `gpu-raw-program.tsx` - the raw shading layer: compileShader/linkProgram/createRenderPipeline/createShaderTarget, one vertex stage shared by two programs, with and without the standard header.
|
|
53
53
|
- `gpu-pipeline.tsx` - `createPipelineTexture`: a custom vertex+fragment pair drawing an interleaved vertex buffer (position and color attributes resolved by name), a depth-tested spinning cube whose `uTime` uniform is driven through `<texture params>` exactly like a fragment shader's.
|
|
54
|
+
- `gpu-shader-chain.tsx` - a chain of targets: a plasma `createShaderTexture` bound as a cube `createPipelineTexture`'s sampler input (`textures: { uMap: plasmaId }`). Sampler bindings are live dependencies, so the app drives only the plasma's `uTime` and the cube re-renders after it every frame, in that order, with no uniform and no per-frame code of its own. The plasma shows beside the cube on the `<texture>` that carries its params.
|
|
54
55
|
- `gpu-particles.tsx` - an additive particle field: `topology: "points"` with `blend: "add"`, so overlapping gaussian splats (`gl_PointSize` from the vertex stage, `gl_PointCoord` falloff, premultiplied output) accumulate into a glowing volume instead of overwriting. The tints are typed vec3 uniforms filled from 3-number array params.
|
|
55
56
|
- `gpu-instancing.tsx` - instanced drawing: one 3-vertex triangle drawn hundreds of times via `instanceCount`, each instance placed and tinted on a phyllotaxis spiral from `gl_InstanceID` alone. `setDraw(id, { instanceCount })` merges into the draw range per frame (absent keys keep their values, like params), so the population breathes without touching the buffer.
|
|
56
57
|
- `gpu-draw-list.tsx` - `createDrawTarget`: one render target holding an ordered, mutable LIST of draws - two orbiting triangles from different programs sharing one depth buffer (the target owns the depth storage, each pipeline the test/write behavior), a third entry added and removed live via its stable `DrawId`, and `setDrawParams` as the per-object channel.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// A chain of targets: a plasma fragment pass feeding a cube pipeline that
|
|
2
|
+
// samples it. Sampler bindings are live dependencies (the model in
|
|
3
|
+
// @solidrt/core/docs/reference/gpu.md): the cube's target is bound to the
|
|
4
|
+
// plasma's through `textures`, so whenever the plasma re-renders, the cube
|
|
5
|
+
// re-renders after it in the same frame, in that order. The app therefore
|
|
6
|
+
// drives only the first pass: the plasma's uTime through the <texture>
|
|
7
|
+
// params prop, as in gpu-shader.tsx. The cube has no uniform of its own
|
|
8
|
+
// and no per-frame code; its view is fixed in the vertex shader, and its
|
|
9
|
+
// faces move because what they sample moves.
|
|
10
|
+
//
|
|
11
|
+
// The plasma is shown beside the cube because that <texture> is where its
|
|
12
|
+
// params prop lives. A source pass that is not on screen is driven the same
|
|
13
|
+
// way from any <texture> of its id. The cube's buffers and index buffer are
|
|
14
|
+
// the gpu-pipeline.tsx mesh with a UV per corner in place of a color.
|
|
15
|
+
import { render, onFrame, createSignal } from "@solidrt/core"
|
|
16
|
+
import { createBuffer, createPipelineTexture, createShaderTexture, glsl } from "@solidrt/core/gpu"
|
|
17
|
+
|
|
18
|
+
// The plasma target's side: what the cube's faces sample.
|
|
19
|
+
let PLASMA_SIZE = 256
|
|
20
|
+
// The cube target's side: what the window shows.
|
|
21
|
+
let CUBE_SIZE = 512
|
|
22
|
+
|
|
23
|
+
// Pass 1: a plasma, the only thing the app animates.
|
|
24
|
+
let PLASMA = glsl`
|
|
25
|
+
uniform float uTime;
|
|
26
|
+
void main() {
|
|
27
|
+
vec2 uv = vUV * 6.0;
|
|
28
|
+
float t = uTime;
|
|
29
|
+
float v = sin(uv.x + t) + sin(uv.y + t * 0.8) + sin((uv.x + uv.y) * 0.7 + t * 1.3)
|
|
30
|
+
+ sin(length(uv - 3.0) * 1.5 - t);
|
|
31
|
+
vec3 col = 0.5 + 0.5 * cos(v * 1.2 + vec3(0.0, 2.1, 4.2));
|
|
32
|
+
fragColor = vec4(col, 1.0);
|
|
33
|
+
}
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
// Pass 2: the cube, viewed from one fixed angle. The projection rig and the
|
|
37
|
+
// clip-y flip are gpu-pipeline.tsx's.
|
|
38
|
+
let VERTEX = glsl`
|
|
39
|
+
in vec3 aPos;
|
|
40
|
+
in vec2 aUV;
|
|
41
|
+
out vec2 vTex;
|
|
42
|
+
|
|
43
|
+
void main() {
|
|
44
|
+
float cy = cos(0.7), sy = sin(0.7);
|
|
45
|
+
float cx = cos(0.5), sx = sin(0.5);
|
|
46
|
+
mat3 rotY = mat3(cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy);
|
|
47
|
+
mat3 rotX = mat3(1.0, 0.0, 0.0, 0.0, cx, sx, 0.0, -sx, cx);
|
|
48
|
+
vec3 p = rotX * (rotY * aPos);
|
|
49
|
+
p.z -= 2.5;
|
|
50
|
+
float w = -p.z;
|
|
51
|
+
float f = 2.0;
|
|
52
|
+
float a = 11.0 / 9.0;
|
|
53
|
+
float b = -20.0 / 9.0;
|
|
54
|
+
gl_Position = vec4(p.x * f, -p.y * f, w * a + b, w);
|
|
55
|
+
vTex = aUV;
|
|
56
|
+
}
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
// The sampler is the chain: uMap is bound to the plasma target below.
|
|
60
|
+
let FRAGMENT = glsl`
|
|
61
|
+
in vec2 vTex;
|
|
62
|
+
uniform sampler2D uMap;
|
|
63
|
+
void main() {
|
|
64
|
+
fragColor = texture(uMap, vTex);
|
|
65
|
+
}
|
|
66
|
+
`
|
|
67
|
+
|
|
68
|
+
// Interleaved [pos vec3, uv vec2]: 24 vertices, 4 per face, each face the
|
|
69
|
+
// whole plasma once. Every face winds counter-clockwise seen from outside.
|
|
70
|
+
function cubeVertices(): Float32Array {
|
|
71
|
+
type Vec3 = [number, number, number]
|
|
72
|
+
let verts: number[] = []
|
|
73
|
+
let quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3) => {
|
|
74
|
+
let uvs = [[0, 1], [1, 1], [1, 0], [0, 0]]
|
|
75
|
+
for (let [i, p] of [a, b, c, d].entries()) verts.push(p[0], p[1], p[2], uvs[i]![0]!, uvs[i]![1]!)
|
|
76
|
+
}
|
|
77
|
+
let s = 0.5
|
|
78
|
+
quad([-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]) // front
|
|
79
|
+
quad([s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]) // back
|
|
80
|
+
quad([s, -s, s], [s, -s, -s], [s, s, -s], [s, s, s]) // right
|
|
81
|
+
quad([-s, -s, -s], [-s, -s, s], [-s, s, s], [-s, s, -s]) // left
|
|
82
|
+
quad([-s, s, s], [s, s, s], [s, s, -s], [-s, s, -s]) // top
|
|
83
|
+
quad([-s, -s, -s], [s, -s, -s], [s, -s, s], [-s, -s, s]) // bottom
|
|
84
|
+
return new Float32Array(verts)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Two triangles per face over its 4 shared vertices: 36 uint16 indices.
|
|
88
|
+
function cubeIndices(): Uint16Array {
|
|
89
|
+
let indices: number[] = []
|
|
90
|
+
for (let face = 0; face < 6; face++) {
|
|
91
|
+
let v = face * 4
|
|
92
|
+
indices.push(v, v + 1, v + 2, v, v + 2, v + 3)
|
|
93
|
+
}
|
|
94
|
+
return new Uint16Array(indices)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function App() {
|
|
98
|
+
let plasmaId = createShaderTexture(PLASMA, PLASMA_SIZE, PLASMA_SIZE, { uTime: 0 }, { label: "plasma" })
|
|
99
|
+
let bufferId = createBuffer(cubeVertices(), { label: "cube-verts" })
|
|
100
|
+
let indexId = createBuffer(cubeIndices(), { label: "cube-indices" })
|
|
101
|
+
// No params: nothing of the cube's is ever written. Its one input is the
|
|
102
|
+
// plasma target, and that binding is what re-renders it.
|
|
103
|
+
let cubeId = createPipelineTexture(VERTEX, FRAGMENT, CUBE_SIZE, CUBE_SIZE, null, {
|
|
104
|
+
label: "cube",
|
|
105
|
+
textures: { uMap: plasmaId },
|
|
106
|
+
buffers: [
|
|
107
|
+
{
|
|
108
|
+
attributes: [
|
|
109
|
+
{ name: "aPos", format: "float32x3" },
|
|
110
|
+
{ name: "aUV", format: "float32x2" },
|
|
111
|
+
],
|
|
112
|
+
buffer: bufferId,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
indexBuffer: indexId,
|
|
116
|
+
indexFormat: "uint16",
|
|
117
|
+
depth: true,
|
|
118
|
+
cull: "back",
|
|
119
|
+
clearColor: [0.08, 0.08, 0.12, 1],
|
|
120
|
+
})
|
|
121
|
+
let [time, setTime] = createSignal(0)
|
|
122
|
+
onFrame((tick) => setTime(tick / 1000))
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<window alignItems="center" justifyContent="center" flexDirection="row" gap={24}>
|
|
126
|
+
<texture src={cubeId} width={CUBE_SIZE} height={CUBE_SIZE} />
|
|
127
|
+
<view flexDirection="column" alignItems="center" gap={8}>
|
|
128
|
+
<texture src={plasmaId} params={{ uTime: time() }} width={PLASMA_SIZE} height={PLASMA_SIZE} />
|
|
129
|
+
<text fontSize={14} color="#888">the plasma pass, the only one driven</text>
|
|
130
|
+
</view>
|
|
131
|
+
</window>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
render(() => <App />)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.72",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"AGENTS.md"
|
|
34
34
|
],
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@solidrt/flux-types": "0.0.
|
|
37
|
-
"@solidrt/test": "0.0.
|
|
36
|
+
"@solidrt/flux-types": "0.0.72",
|
|
37
|
+
"@solidrt/test": "0.0.72"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@solidjs/signals": "2.0.0-rc.13",
|
package/src/core.ts
CHANGED
|
@@ -391,15 +391,18 @@ export function prepareText(text: string, options?: tree.MeasureTextOptions): tr
|
|
|
391
391
|
}
|
|
392
392
|
|
|
393
393
|
/**
|
|
394
|
-
* Makes
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
394
|
+
* Makes glyph cells ahead of their first use, off the frame: call it at
|
|
395
|
+
* startup with the type scale the app draws in (the components' Window
|
|
396
|
+
* does it for the theme's), so a screen's first paint in any of them never
|
|
397
|
+
* waits for cells. Each entry names a font as a <text> would (line height
|
|
398
|
+
* and maxLines are ignored) and warms its printable ASCII; an entry with a
|
|
399
|
+
* `text` also warms the glyphs that string shapes to in the font (a
|
|
400
|
+
* ligature, a curly quote, a letter beyond ASCII), so a headline or a
|
|
401
|
+
* label the app knows it will draw is warm too. Anything else is made at
|
|
402
|
+
* first use.
|
|
400
403
|
*/
|
|
401
|
-
export function warmText(
|
|
402
|
-
tree.warmText(
|
|
404
|
+
export function warmText(entries: tree.WarmTextEntry[]): void {
|
|
405
|
+
tree.warmText(entries)
|
|
403
406
|
}
|
|
404
407
|
|
|
405
408
|
export type TextRenderingOptions = tree.TextRenderingOptions
|
package/src/gpu.ts
CHANGED
|
@@ -152,6 +152,13 @@ export {
|
|
|
152
152
|
export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
|
|
153
153
|
export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute, VertexBufferLayout, VertexFormat } from "flux:gpu"
|
|
154
154
|
|
|
155
|
+
// The vertex vocabulary as data (vertex.ts): the format codecs, the
|
|
156
|
+
// arithmetic over an attribute list and the record accessors that the 3d
|
|
157
|
+
// geometry layouts, the 3d instance streams and the 2d material style
|
|
158
|
+
// streams all run on.
|
|
159
|
+
export { attributeAccess, checkLayout, encodeRecord, formatFeeds, isFloatFormat, isFloatLayout, layoutBytes, layoutComponents, layoutFields, layoutKey, layoutSlot, layoutStride, layoutView, writeRecord, VERTEX_FORMATS } from "./vertex.ts"
|
|
160
|
+
export type { AttributeAccess, FormatCodec, FormatKind } from "./vertex.ts"
|
|
161
|
+
|
|
155
162
|
// The draw-list verbs, re-exported raw: entries live and die with their draw
|
|
156
163
|
// target (see createDrawTarget below), so there is no per-entry lifetime to
|
|
157
164
|
// wrap. addDraw adds an entry (appended, or inserted via opts.before) and
|
|
@@ -753,6 +760,128 @@ export function writeBuffer(id: gpu.BufferId, data: ArrayBuffer | ArrayBufferVie
|
|
|
753
760
|
gpu.writeBuffer(id, toUint8(data), byteOffset)
|
|
754
761
|
}
|
|
755
762
|
|
|
763
|
+
/**
|
|
764
|
+
* A stream of fixed-size records with a GPU buffer, a CPU mirror and a
|
|
765
|
+
* dirty range: the publish model shared by every JS-stepped population
|
|
766
|
+
* (a 2d sprite layer's records, a 3d mesh's instance streams). Write the
|
|
767
|
+
* mirror, `mark` the records written, then `publish` at the flush: the
|
|
768
|
+
* dirty range goes out as one `writeBuffer` at its byte offset, or the
|
|
769
|
+
* drawn prefix whole through the write lease when the buffer carries an
|
|
770
|
+
* instance order (the engine gathers the copy into key order, and a byte
|
|
771
|
+
* range has no stable position under a permutation). Growth replaces the
|
|
772
|
+
* buffer and the mirror (never a resize) and marks what the mirror held,
|
|
773
|
+
* so the next publish fills the replacement; the old buffer is the
|
|
774
|
+
* caller's to destroy once nothing binds it. Read `buffer` and `bytes` at
|
|
775
|
+
* use time: both are replaced by growth.
|
|
776
|
+
*/
|
|
777
|
+
export type RecordStream = {
|
|
778
|
+
/** The GPU buffer the records publish to; replaced by `grow`. */
|
|
779
|
+
readonly buffer: gpu.BufferId
|
|
780
|
+
/** The mirror, `capacity * stride` bytes; replaced by `grow`. View it as
|
|
781
|
+
* the record layout wants (a Float32Array over it for an all-float
|
|
782
|
+
* record). */
|
|
783
|
+
readonly bytes: Uint8Array<ArrayBuffer>
|
|
784
|
+
/** Bytes per record. */
|
|
785
|
+
readonly stride: number
|
|
786
|
+
/** Records the mirror and the buffer hold. */
|
|
787
|
+
readonly capacity: number
|
|
788
|
+
/** Whether any record is marked since the last publish. */
|
|
789
|
+
readonly dirty: boolean
|
|
790
|
+
/** Extend the dirty range over records [first, last). */
|
|
791
|
+
mark(first: number, last: number): void
|
|
792
|
+
/** Send what is dirty and clear the mark. `whole`: the first `limit`
|
|
793
|
+
* records go out through the lease regardless of the range (the
|
|
794
|
+
* ordered form, and a replacement buffer's first fill); otherwise the
|
|
795
|
+
* dirty range, clipped to `limit` records (the live sprites, or the
|
|
796
|
+
* capacity when records are written ahead of their count). Nothing is
|
|
797
|
+
* sent for an empty range or a zero `limit`. */
|
|
798
|
+
publish(whole: boolean, limit: number): void
|
|
799
|
+
/** Replace the buffer and the mirror with room for `capacity` records,
|
|
800
|
+
* the old contents copied over and marked whole; returns the OLD buffer
|
|
801
|
+
* for the caller to destroy once its draws point at the new one. */
|
|
802
|
+
grow(capacity: number): gpu.BufferId
|
|
803
|
+
/** Free the buffer. Safe to call more than once. */
|
|
804
|
+
destroy(): void
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Creates a record stream of `capacity` records of `stride` bytes (see
|
|
809
|
+
* {@link RecordStream}): a sized, unwritten GPU buffer and a zeroed
|
|
810
|
+
* mirror. Freed with the reactive owner like a buffer (opt out with
|
|
811
|
+
* `{ autoFree: false }` and call `destroy`).
|
|
812
|
+
*/
|
|
813
|
+
export function createRecordStream(stride: number, capacity: number, opts?: CreateOptions): RecordStream {
|
|
814
|
+
if (!(Number.isInteger(stride) && stride > 0)) throw new Error("createRecordStream: stride must be a positive integer, got " + stride)
|
|
815
|
+
if (!(Number.isInteger(capacity) && capacity > 0)) throw new Error("createRecordStream: capacity must be a positive integer, got " + capacity)
|
|
816
|
+
let buffer = createBuffer(capacity * stride, { label: opts?.label, autoFree: false })
|
|
817
|
+
let bytes = new Uint8Array(capacity * stride)
|
|
818
|
+
let dirty: [number, number] | null = null
|
|
819
|
+
let destroyed = false
|
|
820
|
+
let stream: RecordStream = {
|
|
821
|
+
get buffer() {
|
|
822
|
+
return buffer
|
|
823
|
+
},
|
|
824
|
+
get bytes() {
|
|
825
|
+
return bytes
|
|
826
|
+
},
|
|
827
|
+
stride,
|
|
828
|
+
get capacity() {
|
|
829
|
+
return bytes.length / stride
|
|
830
|
+
},
|
|
831
|
+
get dirty() {
|
|
832
|
+
return dirty !== null
|
|
833
|
+
},
|
|
834
|
+
mark(first, last) {
|
|
835
|
+
let capacity = bytes.length / stride
|
|
836
|
+
if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last > capacity || first > last) {
|
|
837
|
+
throw new Error("RecordStream.mark: range [" + first + ", " + last + ") is outside the stream's " + capacity + " records")
|
|
838
|
+
}
|
|
839
|
+
if (first === last) return
|
|
840
|
+
if (dirty === null) dirty = [first, last]
|
|
841
|
+
else {
|
|
842
|
+
if (first < dirty[0]) dirty[0] = first
|
|
843
|
+
if (last > dirty[1]) dirty[1] = last
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
publish(whole, limit) {
|
|
847
|
+
let range = dirty
|
|
848
|
+
dirty = null
|
|
849
|
+
if (whole) {
|
|
850
|
+
let byteLength = Math.min(limit, bytes.length / stride) * stride
|
|
851
|
+
if (byteLength <= 0) return
|
|
852
|
+
let out = beginBufferWrite(buffer)
|
|
853
|
+
new Uint8Array(out.buffer, 0, byteLength).set(bytes.subarray(0, byteLength))
|
|
854
|
+
gpu.endBufferWrite(buffer, byteLength)
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
if (range === null) return
|
|
858
|
+
let lo = range[0]
|
|
859
|
+
let hi = Math.min(range[1], limit)
|
|
860
|
+
if (hi > lo) writeBuffer(buffer, bytes.subarray(lo * stride, hi * stride), lo * stride)
|
|
861
|
+
},
|
|
862
|
+
grow(next) {
|
|
863
|
+
let previous = bytes.length / stride
|
|
864
|
+
if (!(Number.isInteger(next) && next > previous)) {
|
|
865
|
+
throw new Error("RecordStream.grow: capacity must be an integer above the current " + previous + ", got " + next)
|
|
866
|
+
}
|
|
867
|
+
let old = buffer
|
|
868
|
+
buffer = createBuffer(next * stride, { label: opts?.label, autoFree: false })
|
|
869
|
+
let grown = new Uint8Array(next * stride)
|
|
870
|
+
grown.set(bytes)
|
|
871
|
+
bytes = grown
|
|
872
|
+
dirty = [0, previous]
|
|
873
|
+
return old
|
|
874
|
+
},
|
|
875
|
+
destroy() {
|
|
876
|
+
if (destroyed) return
|
|
877
|
+
destroyed = true
|
|
878
|
+
gpu.destroyBuffer(buffer)
|
|
879
|
+
},
|
|
880
|
+
}
|
|
881
|
+
if (opts?.autoFree !== false && getOwner()) onCleanup(() => stream.destroy())
|
|
882
|
+
return stream
|
|
883
|
+
}
|
|
884
|
+
|
|
756
885
|
/**
|
|
757
886
|
* Hands an ordered instance buffer's full record set to the engine - the
|
|
758
887
|
* write-once form: the engine keeps the one CPU copy and sorts,
|
package/src/index.ts
CHANGED
|
@@ -58,7 +58,7 @@ export type {
|
|
|
58
58
|
Element,
|
|
59
59
|
LayoutProps,
|
|
60
60
|
TransformProps,
|
|
61
|
-
|
|
61
|
+
InputProps,
|
|
62
62
|
TransitionProps,
|
|
63
63
|
Transition,
|
|
64
64
|
TransitionCurve,
|
|
@@ -94,7 +94,7 @@ export type {
|
|
|
94
94
|
Color,
|
|
95
95
|
Pct,
|
|
96
96
|
} from "./types"
|
|
97
|
-
export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
|
|
97
|
+
export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit, WarmTextEntry } from "flux:rendertree"
|
|
98
98
|
|
|
99
99
|
// A percentage value for dimensional props (e.g. transformOrigin): `pct(50)` is
|
|
100
100
|
// half the element box. Keeps percentages a first-class branded value rather
|
|
@@ -119,6 +119,8 @@ export {
|
|
|
119
119
|
createRenderEffect,
|
|
120
120
|
createRoot,
|
|
121
121
|
createStore,
|
|
122
|
+
createProjection,
|
|
123
|
+
snapshot,
|
|
122
124
|
reconcile,
|
|
123
125
|
merge,
|
|
124
126
|
omit,
|
|
@@ -131,6 +133,22 @@ export {
|
|
|
131
133
|
} from "@solidjs/signals"
|
|
132
134
|
export type { Accessor, Setter, Signal, Store, StoreSetter } from "@solidjs/signals"
|
|
133
135
|
|
|
136
|
+
// Async and optimistic state (from @solidjs/signals). `isPending` and
|
|
137
|
+
// `latest` read an async value's in-flight change, `refresh` re-asks it
|
|
138
|
+
// and `affects` marks it as changing; `action` runs a mutation as a
|
|
139
|
+
// transition, and the optimistic primitives show the expected value until
|
|
140
|
+
// it completes. See CHEATSHEET.md "Async" and "Actions & optimistic".
|
|
141
|
+
export {
|
|
142
|
+
action,
|
|
143
|
+
createOptimistic,
|
|
144
|
+
createOptimisticStore,
|
|
145
|
+
isPending,
|
|
146
|
+
latest,
|
|
147
|
+
refresh,
|
|
148
|
+
affects,
|
|
149
|
+
} from "@solidjs/signals"
|
|
150
|
+
export type { Refreshable } from "@solidjs/signals"
|
|
151
|
+
|
|
134
152
|
// Owner and lifecycle (from @solidjs/signals). `createRoot` above is the fourth
|
|
135
153
|
// member of this group - it creates an owned scope, these reach the owner it
|
|
136
154
|
// established. Capture an owner to restore it across an async boundary
|
package/src/renderer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// createErrorBoundary comes from signals, not solid-js: solid-js marks it
|
|
2
2
|
// @internal (an app writes <Errored>, a renderer reaches for the primitive)
|
|
3
3
|
// and leaves it out of its public types.
|
|
4
|
-
import { createErrorBoundary, createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
|
|
4
|
+
import { createErrorBoundary, createRoot, onCleanup, NotReadyError, runWithOwner, untrack } from "@solidjs/signals"
|
|
5
5
|
import { createRenderer } from "@solidjs/universal"
|
|
6
6
|
import type { Element } from "solid-js"
|
|
7
7
|
import * as tree from "flux:rendertree"
|
|
@@ -122,6 +122,13 @@ let warnedLeakTypes = new Set<string>()
|
|
|
122
122
|
// warning: a leak that keeps growing at a stable set of types warns again
|
|
123
123
|
// each time the total crosses the next power of ten.
|
|
124
124
|
let warnedMagnitude = -1
|
|
125
|
+
// The other cause of orphans: a subtree whose build a throw cut short (a
|
|
126
|
+
// child expression or component body that threw an error, or a pending
|
|
127
|
+
// read on its way to <Loading>, which retries and abandons again). The
|
|
128
|
+
// elements created before the throw are never inserted. Counted where
|
|
129
|
+
// such throws pass (insert's guard, the root boundary) so the warning can
|
|
130
|
+
// point at it (okf/backlog/suspend-retry-orphan-elements.md).
|
|
131
|
+
let abandonedBuilds = 0
|
|
125
132
|
|
|
126
133
|
export function scanForOrphans(now: number): void {
|
|
127
134
|
if (!import.meta.env.DEV) return
|
|
@@ -144,11 +151,17 @@ export function scanForOrphans(now: number): void {
|
|
|
144
151
|
for (let [type] of fresh) warnedLeakTypes.add(type)
|
|
145
152
|
warnedMagnitude = magnitude
|
|
146
153
|
let list = [...counts].map(([type, n]) => `<${type}> x${n}`).join(", ")
|
|
154
|
+
let abandoned =
|
|
155
|
+
abandonedBuilds === 0
|
|
156
|
+
? ""
|
|
157
|
+
: `A build cut short by a throw leaves what it had created unreachable too: ${abandonedBuilds} ` +
|
|
158
|
+
`such throws so far (an error in a child expression or component body, or a pending read ` +
|
|
159
|
+
`on its way to <Loading>, which retries and abandons again). `
|
|
147
160
|
console.warn(
|
|
148
161
|
`Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
|
|
149
162
|
`The usual cause is reading an element-valued prop more than once (every read ` +
|
|
150
163
|
`builds a new subtree); read it once where it mounts, or resolve it with ` +
|
|
151
|
-
`children(). If these nodes are intentionally kept for later mounting, ignore ` +
|
|
164
|
+
`children(). ${abandoned}If these nodes are intentionally kept for later mounting, ignore ` +
|
|
152
165
|
`this. The next warning comes when a new element type joins the list or the ` +
|
|
153
166
|
`total passes ${10 ** (magnitude + 1)}.`,
|
|
154
167
|
)
|
|
@@ -345,6 +358,28 @@ export let { memo, createComponent, createElement, createTextNode, insertNode, s
|
|
|
345
358
|
renderer
|
|
346
359
|
let { effect: rawEffect, insert: rawInsert } = renderer
|
|
347
360
|
|
|
361
|
+
/** A ref callback as a component prop: a function, or an array of them. */
|
|
362
|
+
export type RefCallback<T> = ((handle: T) => void) | RefCallback<T>[]
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Hands `handle` to a component's `ref` callback prop the way Solid hands
|
|
366
|
+
* an element to an element's `ref`: read untracked, called outside any
|
|
367
|
+
* owner, so a signal setter passed straight in (`ref={setLayer}`) writes
|
|
368
|
+
* without tripping the owned-scope guard. A component body is an owned
|
|
369
|
+
* scope, so calling the prop directly there throws
|
|
370
|
+
* REACTIVE_WRITE_IN_OWNED_SCOPE in dev; `untrack` does not lift that.
|
|
371
|
+
* Accepts an array of callbacks like Solid's applyRef does.
|
|
372
|
+
*/
|
|
373
|
+
export function callRef<T>(read: () => RefCallback<T> | undefined, handle: T): void {
|
|
374
|
+
let callback = untrack(read)
|
|
375
|
+
if (callback === undefined) return
|
|
376
|
+
runWithOwner(null, () => {
|
|
377
|
+
if (Array.isArray(callback)) {
|
|
378
|
+
for (let f of (callback as unknown[]).flat(Infinity) as ((handle: T) => void)[]) f(handle)
|
|
379
|
+
} else callback(handle)
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
348
383
|
// ------ Per-node error containment --------
|
|
349
384
|
//
|
|
350
385
|
// Every reactive write into the tree goes through two exports the compiled
|
|
@@ -382,6 +417,9 @@ function guard<T>(fn: (prev?: T) => T, describe: () => string, nested: boolean,
|
|
|
382
417
|
last = value
|
|
383
418
|
return value
|
|
384
419
|
} catch (e) {
|
|
420
|
+
// Only a child expression builds a subtree a throw can abandon; a
|
|
421
|
+
// prop expression creates no elements.
|
|
422
|
+
if (nested) abandonedBuilds += 1
|
|
385
423
|
if (e instanceof NotReadyError) throw e
|
|
386
424
|
if (!failing) {
|
|
387
425
|
failing = true
|
|
@@ -456,6 +494,7 @@ export function render(code: () => any) {
|
|
|
456
494
|
(error, reset) => {
|
|
457
495
|
// The boundary hands the error as an accessor.
|
|
458
496
|
let err = error()
|
|
497
|
+
abandonedBuilds += 1
|
|
459
498
|
console.error("Uncaught error: the app is replaced by the error window until reset or reload.", err)
|
|
460
499
|
let win = errorWindow(err, reset)
|
|
461
500
|
errorWindows.add(win.id)
|
package/src/types.d.ts
CHANGED
|
@@ -381,7 +381,9 @@ export interface WheelEvent extends PointerEvent {
|
|
|
381
381
|
// Routing: keydown/keyup dispatch along the focused node's ancestor chain,
|
|
382
382
|
// leaf->root, always ending at the window root; with nothing focused they go
|
|
383
383
|
// to the window root alone. <window onKeyDown> is therefore the app-global
|
|
384
|
-
// shortcut point.
|
|
384
|
+
// shortcut point. An Escape keydown nobody consumed then runs the back
|
|
385
|
+
// handler stack (onBack) without its platform default, so a control that
|
|
386
|
+
// takes Escape itself calls stopPropagation.
|
|
385
387
|
export interface KeyEvent {
|
|
386
388
|
/** When the key event happened, in milliseconds, on the clock of
|
|
387
389
|
* `PointerEvent.timeStamp`. */
|
|
@@ -421,7 +423,7 @@ export interface NodeProps {
|
|
|
421
423
|
label?: string
|
|
422
424
|
}
|
|
423
425
|
|
|
424
|
-
export interface
|
|
426
|
+
export interface InputProps extends NodeProps {
|
|
425
427
|
onPointerDown?: (event: PointerEvent) => void
|
|
426
428
|
onPointerUp?: (event: PointerEvent) => void
|
|
427
429
|
/**
|
|
@@ -456,6 +458,15 @@ export interface PointerProps extends NodeProps {
|
|
|
456
458
|
* still moves through setFocus.
|
|
457
459
|
*/
|
|
458
460
|
focusable?: boolean
|
|
461
|
+
/**
|
|
462
|
+
* Hit testing, with CSS pointer-events inheritance: unset inherits the
|
|
463
|
+
* nearest ancestor's value (root: "auto"). "none" makes the element and
|
|
464
|
+
* its subtree transparent and drops it from the bubble path; a descendant
|
|
465
|
+
* that sets "auto" or "all" opts back in. "auto" hits the element's box
|
|
466
|
+
* with children deepest-first. "all" (not CSS's) hits the box as one
|
|
467
|
+
* target and never descends: the whole element is the target whatever its
|
|
468
|
+
* children's shapes, nothing outside the box is captured.
|
|
469
|
+
*/
|
|
459
470
|
pointerEvents?: "auto" | "none" | "all"
|
|
460
471
|
/**
|
|
461
472
|
* The mouse cursor while this element is hovered: a platform shape by its
|
|
@@ -762,7 +773,7 @@ export interface TransitionProps {
|
|
|
762
773
|
|
|
763
774
|
// Primitives
|
|
764
775
|
|
|
765
|
-
export interface WindowProps extends LayoutProps,
|
|
776
|
+
export interface WindowProps extends LayoutProps, InputProps, TransitionProps {
|
|
766
777
|
children?: Children
|
|
767
778
|
title?: string
|
|
768
779
|
fullscreen?: boolean
|
|
@@ -817,7 +828,7 @@ export interface WindowShaderProps {
|
|
|
817
828
|
// Everything a view offers besides layout: d-view uses this directly (a
|
|
818
829
|
// detached view has no taffy presence, so layout props would be rejected at
|
|
819
830
|
// runtime); the layout `view` adds LayoutProps below.
|
|
820
|
-
export interface ViewOwnProps extends TransformProps,
|
|
831
|
+
export interface ViewOwnProps extends TransformProps, InputProps {
|
|
821
832
|
children?: Children
|
|
822
833
|
trace?: boolean
|
|
823
834
|
/**
|
|
@@ -970,7 +981,7 @@ export interface ViewProps extends ViewOwnProps, LayoutProps {}
|
|
|
970
981
|
// the box for a clip to cut. `path` and `line` strokes stay centered on their
|
|
971
982
|
// geometry - there the geometry is the stroke, not a box. A dashed stroke
|
|
972
983
|
// dashes that same inset outline (see DashProps).
|
|
973
|
-
export interface RectProps extends PaintProps,
|
|
984
|
+
export interface RectProps extends PaintProps, InputProps, DashProps {
|
|
974
985
|
// Corner radius, measured on the box (the stroke's outer edge). A single
|
|
975
986
|
// number applies to all four corners; an array is [top-left, top-right,
|
|
976
987
|
// bottom-right, bottom-left] (CSS border-radius order).
|
|
@@ -979,7 +990,7 @@ export interface RectProps extends PaintProps, PointerProps, DashProps {
|
|
|
979
990
|
}
|
|
980
991
|
|
|
981
992
|
// Strokes paint inside the box, same as `RectProps`.
|
|
982
|
-
export interface OvalProps extends PaintProps,
|
|
993
|
+
export interface OvalProps extends PaintProps, InputProps, DashProps {
|
|
983
994
|
shadow?: ShadowProps
|
|
984
995
|
}
|
|
985
996
|
|
|
@@ -1036,7 +1047,7 @@ export interface DashProps {
|
|
|
1036
1047
|
// fill). On a polyline "fill" and "stroke-and-fill" fill the polygon
|
|
1037
1048
|
// (nonzero, implicitly closed) and hit-test its interior; on the two-point
|
|
1038
1049
|
// form fill has no effect, a segment has no interior.
|
|
1039
|
-
export interface LineProps extends PaintProps,
|
|
1050
|
+
export interface LineProps extends PaintProps, InputProps, DashProps {
|
|
1040
1051
|
/**
|
|
1041
1052
|
* Polyline vertices as a flat [x0, y0, x1, y1, ...] in the element's local
|
|
1042
1053
|
* space (the space x1..y2 use). Takes precedence over the endpoints while
|
|
@@ -1061,7 +1072,7 @@ export interface LineProps extends PaintProps, PointerProps, DashProps {
|
|
|
1061
1072
|
* points) plus the stroke's reach, at a `d-path`'s x/y - not its layout box
|
|
1062
1073
|
* or the inherited one.
|
|
1063
1074
|
*/
|
|
1064
|
-
export interface PathProps extends PaintProps,
|
|
1075
|
+
export interface PathProps extends PaintProps, InputProps, DashProps {
|
|
1065
1076
|
d?: string
|
|
1066
1077
|
fillRule?: "nonzero" | "evenodd"
|
|
1067
1078
|
/** Shadows the drawn silhouette (fill and/or stroke); `spread` is rejected here. */
|
|
@@ -1128,12 +1139,12 @@ export interface TextRunProps {
|
|
|
1128
1139
|
* Pointer handlers fire for the boxes of the run's own text on each line it
|
|
1129
1140
|
* spans and bubble to the enclosing spans and text.
|
|
1130
1141
|
*/
|
|
1131
|
-
export interface SpanProps extends TextRunProps,
|
|
1142
|
+
export interface SpanProps extends TextRunProps, InputProps {
|
|
1132
1143
|
children?: Children
|
|
1133
1144
|
color?: Color | Gradient
|
|
1134
1145
|
}
|
|
1135
1146
|
|
|
1136
|
-
export interface TextProps extends PaintProps,
|
|
1147
|
+
export interface TextProps extends PaintProps, InputProps, TextRunProps {
|
|
1137
1148
|
children?: Children
|
|
1138
1149
|
textAlign?: TextAlign
|
|
1139
1150
|
maxLines?: number
|
|
@@ -1174,7 +1185,7 @@ export interface TextProps extends PaintProps, PointerProps, TextRunProps {
|
|
|
1174
1185
|
* Texture alpha is premultiplied, so additive modes need no manual
|
|
1175
1186
|
* premultiplication.
|
|
1176
1187
|
*/
|
|
1177
|
-
export interface TextureProps extends PaintProps,
|
|
1188
|
+
export interface TextureProps extends PaintProps, InputProps {
|
|
1178
1189
|
src?: TextureId
|
|
1179
1190
|
/**
|
|
1180
1191
|
* Corner radius, as on a rect: the image is drawn as a rounded rect filled
|
package/src/vertex.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// The vertex vocabulary as data: one codec per vertex format (the engine's
|
|
2
|
+
// table in the same spelling, so it is also the list a declared format
|
|
3
|
+
// must be one of), and the arithmetic over an attribute LIST - the stride
|
|
4
|
+
// it interleaves to, its identity key, where a name sits, whether a
|
|
5
|
+
// Float32Array can hold it - plus the accessors that read and write one
|
|
6
|
+
// attribute of a record through its codec, so no caller ever knows an
|
|
7
|
+
// offset, a stride or a format. @solidrt/3d's geometry layouts and
|
|
8
|
+
// instance streams and @solidrt/2d's material style streams run on this;
|
|
9
|
+
// the named presets (a 3d geometry's "base" layout) stay in the packages.
|
|
10
|
+
//
|
|
11
|
+
// Every format is a multiple of 4 bytes (WebGPU's alignment rule), which
|
|
12
|
+
// is what keeps every offset and stride 4-aligned with no padding
|
|
13
|
+
// arithmetic anywhere, and a Float32Array view over any record buffer
|
|
14
|
+
// always valid.
|
|
15
|
+
|
|
16
|
+
import type { VertexAttribute, VertexFormat } from "flux:gpu"
|
|
17
|
+
|
|
18
|
+
/** The shader `in` family a vertex format feeds (WebGPU's rule: the
|
|
19
|
+
* format decides): "float" for the float and normalized formats (`in
|
|
20
|
+
* vec*`), "uint" for uint* (`in uvec*`), "sint" for sint* (`in ivec*`). */
|
|
21
|
+
export type FormatKind = "float" | "uint" | "sint"
|
|
22
|
+
|
|
23
|
+
/** One vertex format's codec: its size, its component count, its kind,
|
|
24
|
+
* and the read and write of component `k` of an attribute at byte `at`
|
|
25
|
+
* of a DataView, in the value the shader sees (a normalized integer
|
|
26
|
+
* decodes to 0..1 / -1..1, an unnormalized one to its exact value). */
|
|
27
|
+
export type FormatCodec = {
|
|
28
|
+
bytes: number
|
|
29
|
+
components: number
|
|
30
|
+
kind: FormatKind
|
|
31
|
+
get(dv: DataView, at: number, k: number): number
|
|
32
|
+
set(dv: DataView, at: number, k: number, v: number): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The integer component kinds behind the packed formats: width, the
|
|
36
|
+
// value that maps to 1 (GL ES 3.0's normalization rule, -max mapping to
|
|
37
|
+
// -1 and the one value below it clamped there), and the raw accessors.
|
|
38
|
+
type IntKind = { bytes: number; max: number; get(dv: DataView, at: number): number; set(dv: DataView, at: number, v: number): void }
|
|
39
|
+
const U8: IntKind = { bytes: 1, max: 255, get: (dv, at) => dv.getUint8(at), set: (dv, at, v) => dv.setUint8(at, v) }
|
|
40
|
+
const S8: IntKind = { bytes: 1, max: 127, get: (dv, at) => dv.getInt8(at), set: (dv, at, v) => dv.setInt8(at, v) }
|
|
41
|
+
const U16: IntKind = { bytes: 2, max: 65535, get: (dv, at) => dv.getUint16(at, true), set: (dv, at, v) => dv.setUint16(at, v, true) }
|
|
42
|
+
const S16: IntKind = { bytes: 2, max: 32767, get: (dv, at) => dv.getInt16(at, true), set: (dv, at, v) => dv.setInt16(at, v, true) }
|
|
43
|
+
const U32: IntKind = { bytes: 4, max: 4294967295, get: (dv, at) => dv.getUint32(at, true), set: (dv, at, v) => dv.setUint32(at, v, true) }
|
|
44
|
+
const S32: IntKind = { bytes: 4, max: 2147483647, get: (dv, at) => dv.getInt32(at, true), set: (dv, at, v) => dv.setInt32(at, v, true) }
|
|
45
|
+
|
|
46
|
+
function clamp(v: number, lo: number, hi: number): number {
|
|
47
|
+
return v < lo ? lo : v > hi ? hi : v
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function floatCodec(components: number, bytes: number, get: (dv: DataView, at: number) => number, set: (dv: DataView, at: number, v: number) => void): FormatCodec {
|
|
51
|
+
return {
|
|
52
|
+
bytes: components * bytes,
|
|
53
|
+
components,
|
|
54
|
+
kind: "float",
|
|
55
|
+
get: (dv, at, k) => get(dv, at + k * bytes),
|
|
56
|
+
set: (dv, at, k, v) => set(dv, at + k * bytes, v),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unormCodec(components: number, int: IntKind): FormatCodec {
|
|
61
|
+
return {
|
|
62
|
+
bytes: components * int.bytes,
|
|
63
|
+
components,
|
|
64
|
+
kind: "float",
|
|
65
|
+
get: (dv, at, k) => int.get(dv, at + k * int.bytes) / int.max,
|
|
66
|
+
set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, 0, 1) * int.max)),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function snormCodec(components: number, int: IntKind): FormatCodec {
|
|
71
|
+
return {
|
|
72
|
+
bytes: components * int.bytes,
|
|
73
|
+
components,
|
|
74
|
+
kind: "float",
|
|
75
|
+
get: (dv, at, k) => Math.max(int.get(dv, at + k * int.bytes) / int.max, -1),
|
|
76
|
+
set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, -1, 1) * int.max)),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function uintCodec(components: number, int: IntKind): FormatCodec {
|
|
81
|
+
return {
|
|
82
|
+
bytes: components * int.bytes,
|
|
83
|
+
components,
|
|
84
|
+
kind: "uint",
|
|
85
|
+
get: (dv, at, k) => int.get(dv, at + k * int.bytes),
|
|
86
|
+
set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, 0, int.max))),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sintCodec(components: number, int: IntKind): FormatCodec {
|
|
91
|
+
return {
|
|
92
|
+
bytes: components * int.bytes,
|
|
93
|
+
components,
|
|
94
|
+
kind: "sint",
|
|
95
|
+
get: (dv, at, k) => int.get(dv, at + k * int.bytes),
|
|
96
|
+
set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, -int.max - 1, int.max))),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let getF32 = (dv: DataView, at: number) => dv.getFloat32(at, true)
|
|
101
|
+
let setF32 = (dv: DataView, at: number, v: number) => dv.setFloat32(at, v, true)
|
|
102
|
+
let getF16 = (dv: DataView, at: number) => dv.getFloat16(at, true)
|
|
103
|
+
let setF16 = (dv: DataView, at: number, v: number) => dv.setFloat16(at, v, true)
|
|
104
|
+
|
|
105
|
+
/** The vertex vocabulary: one codec per format (the engine's table in
|
|
106
|
+
* the same spelling), so it is also the list a declared format must be
|
|
107
|
+
* one of. A format feeds the shader `in` of its kind and component
|
|
108
|
+
* count (`formatFeeds`). */
|
|
109
|
+
export const VERTEX_FORMATS: Record<VertexFormat, FormatCodec> = {
|
|
110
|
+
float32: floatCodec(1, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
|
|
111
|
+
float32x2: floatCodec(2, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
|
|
112
|
+
float32x3: floatCodec(3, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
|
|
113
|
+
float32x4: floatCodec(4, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
|
|
114
|
+
float16x2: floatCodec(2, Float16Array.BYTES_PER_ELEMENT, getF16, setF16),
|
|
115
|
+
float16x4: floatCodec(4, Float16Array.BYTES_PER_ELEMENT, getF16, setF16),
|
|
116
|
+
unorm8x4: unormCodec(4, U8),
|
|
117
|
+
snorm8x4: snormCodec(4, S8),
|
|
118
|
+
unorm16x2: unormCodec(2, U16),
|
|
119
|
+
unorm16x4: unormCodec(4, U16),
|
|
120
|
+
snorm16x2: snormCodec(2, S16),
|
|
121
|
+
snorm16x4: snormCodec(4, S16),
|
|
122
|
+
uint8x4: uintCodec(4, U8),
|
|
123
|
+
uint16x2: uintCodec(2, U16),
|
|
124
|
+
uint16x4: uintCodec(4, U16),
|
|
125
|
+
uint32: uintCodec(1, U32),
|
|
126
|
+
uint32x2: uintCodec(2, U32),
|
|
127
|
+
uint32x3: uintCodec(3, U32),
|
|
128
|
+
uint32x4: uintCodec(4, U32),
|
|
129
|
+
sint8x4: sintCodec(4, S8),
|
|
130
|
+
sint16x2: sintCodec(2, S16),
|
|
131
|
+
sint16x4: sintCodec(4, S16),
|
|
132
|
+
sint32: sintCodec(1, S32),
|
|
133
|
+
sint32x2: sintCodec(2, S32),
|
|
134
|
+
sint32x3: sintCodec(3, S32),
|
|
135
|
+
sint32x4: sintCodec(4, S32),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Whether a layout declaring `declared` may feed a program input the
|
|
139
|
+
* engine reflects as `reflected` (float32xN for `vec*`, uint32xN for
|
|
140
|
+
* `uvec*`, sint32xN for `ivec*`): same component count and same kind,
|
|
141
|
+
* the engine's own pipeline rule. */
|
|
142
|
+
export function formatFeeds(declared: VertexFormat, reflected: VertexFormat): boolean {
|
|
143
|
+
let d = VERTEX_FORMATS[declared]
|
|
144
|
+
let r = VERTEX_FORMATS[reflected]
|
|
145
|
+
return d.components === r.components && d.kind === r.kind
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Whether a format is one of the float32 family: what a Float32Array
|
|
149
|
+
* holds directly. */
|
|
150
|
+
export function isFloatFormat(format: VertexFormat): boolean {
|
|
151
|
+
return format.startsWith("float32")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Bytes per record of an attribute list - its interleave stride. */
|
|
155
|
+
export function layoutStride(layout: readonly VertexAttribute[]): number {
|
|
156
|
+
let stride = 0
|
|
157
|
+
for (let attr of layout) stride += VERTEX_FORMATS[attr.format].bytes
|
|
158
|
+
return stride
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Values per record of an attribute list: its attributes' components
|
|
162
|
+
* summed - what a record written as a flat value list counts in. */
|
|
163
|
+
export function layoutComponents(layout: readonly VertexAttribute[]): number {
|
|
164
|
+
let n = 0
|
|
165
|
+
for (let attr of layout) n += VERTEX_FORMATS[attr.format].components
|
|
166
|
+
return n
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** An attribute list's identity as a string (name:format per attribute,
|
|
170
|
+
* in order): two lists with equal keys interleave identically. */
|
|
171
|
+
export function layoutKey(layout: readonly VertexAttribute[]): string {
|
|
172
|
+
return layout.map(a => a.name + ":" + a.format).join(",")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Where an attribute sits in the interleave: its byte offset, format
|
|
176
|
+
* and component count. Null when the list does not carry that name. */
|
|
177
|
+
export function layoutSlot(layout: readonly VertexAttribute[], name: string): { offset: number; format: VertexFormat; components: number } | null {
|
|
178
|
+
let offset = 0
|
|
179
|
+
for (let attr of layout) {
|
|
180
|
+
let codec = VERTEX_FORMATS[attr.format]
|
|
181
|
+
if (attr.name === name) return { offset, format: attr.format, components: codec.components }
|
|
182
|
+
offset += codec.bytes
|
|
183
|
+
}
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether every attribute of a list is float32-family: such a buffer
|
|
188
|
+
* is a Float32Array of `layoutStride / 4` floats per record. */
|
|
189
|
+
export function isFloatLayout(layout: readonly VertexAttribute[]): boolean {
|
|
190
|
+
return layout.every(a => isFloatFormat(a.format))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The check an attribute list must pass before it names a buffer: a
|
|
194
|
+
* known format on every attribute and no name twice. Throws naming
|
|
195
|
+
* `where` (the dev validation policy). */
|
|
196
|
+
export function checkLayout(layout: readonly VertexAttribute[], where: string): void {
|
|
197
|
+
let seen = new Set<string>()
|
|
198
|
+
for (let attr of layout) {
|
|
199
|
+
if (!(attr.format in VERTEX_FORMATS)) {
|
|
200
|
+
throw new Error(where + ": unknown vertex format '" + String(attr.format) + "' for " + attr.name + " (expected " + Object.keys(VERTEX_FORMATS).join(", ") + ")")
|
|
201
|
+
}
|
|
202
|
+
if (seen.has(attr.name)) throw new Error(where + ": duplicate attribute '" + attr.name + "'")
|
|
203
|
+
seen.add(attr.name)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The bytes of a record buffer, whatever view holds them. */
|
|
208
|
+
export function layoutBytes(records: ArrayBufferView): Uint8Array {
|
|
209
|
+
return new Uint8Array(records.buffer, records.byteOffset, records.byteLength)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The view a record buffer of `layout` is handed out as: a Float32Array
|
|
213
|
+
* over an all-float layout (indexable as floats), a Uint8Array over a
|
|
214
|
+
* layout with a packed channel. */
|
|
215
|
+
export function layoutView(layout: readonly VertexAttribute[], buffer: ArrayBuffer, byteOffset = 0, byteLength = buffer.byteLength - byteOffset): ArrayBufferView {
|
|
216
|
+
return isFloatLayout(layout)
|
|
217
|
+
? new Float32Array(buffer, byteOffset, byteLength / Float32Array.BYTES_PER_ELEMENT)
|
|
218
|
+
: new Uint8Array(buffer, byteOffset, byteLength)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A view on one attribute of a record buffer: component `k` of record
|
|
222
|
+
* `i`, read and written as the value the shader sees, through the
|
|
223
|
+
* format's codec. The one way anything touches record data, so no
|
|
224
|
+
* caller knows an offset, a stride or a format. */
|
|
225
|
+
export type AttributeAccess = {
|
|
226
|
+
format: VertexFormat
|
|
227
|
+
components: number
|
|
228
|
+
get(i: number, k: number): number
|
|
229
|
+
set(i: number, k: number, v: number): void
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The accessor for `name` over a bare record buffer of `layout`, bound
|
|
233
|
+
* to that buffer; null when the layout lacks the name. */
|
|
234
|
+
export function attributeAccess(records: ArrayBufferView, layout: readonly VertexAttribute[], name: string): AttributeAccess | null {
|
|
235
|
+
let slot = layoutSlot(layout, name)
|
|
236
|
+
if (slot === null) return null
|
|
237
|
+
let stride = layoutStride(layout)
|
|
238
|
+
let codec = VERTEX_FORMATS[slot.format]
|
|
239
|
+
let offset = slot.offset
|
|
240
|
+
let dv = new DataView(records.buffer, records.byteOffset, records.byteLength)
|
|
241
|
+
return {
|
|
242
|
+
format: slot.format,
|
|
243
|
+
components: codec.components,
|
|
244
|
+
get: (i, k) => codec.get(dv, i * stride + offset, k),
|
|
245
|
+
set: (i, k, v) => codec.set(dv, i * stride + offset, k, v),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One accessor per attribute of `layout`, in order, reading through
|
|
250
|
+
* `view()` at every call - the form for a buffer that growth replaces:
|
|
251
|
+
* the accessors stay valid, the DataView behind them moves. */
|
|
252
|
+
export function layoutFields(layout: readonly VertexAttribute[], view: () => DataView): AttributeAccess[] {
|
|
253
|
+
let stride = layoutStride(layout)
|
|
254
|
+
let offset = 0
|
|
255
|
+
return layout.map(attr => {
|
|
256
|
+
let codec = VERTEX_FORMATS[attr.format]
|
|
257
|
+
let at = offset
|
|
258
|
+
offset += codec.bytes
|
|
259
|
+
return {
|
|
260
|
+
format: attr.format,
|
|
261
|
+
components: codec.components,
|
|
262
|
+
get: (i, k) => codec.get(view(), i * stride + at, k),
|
|
263
|
+
set: (i, k, v) => codec.set(view(), i * stride + at, k, v),
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Encode `values` (one per component, in layout order, as the shader
|
|
269
|
+
* sees them) into record `i` of a buffer: indexed float stores over an
|
|
270
|
+
* all-float layout given as `floats` (the values ARE the bytes; indexed
|
|
271
|
+
* stores beat TypedArray.set over a plain array under QuickJS, which
|
|
272
|
+
* walks the array-like generically), the accessors' codecs otherwise.
|
|
273
|
+
* Throws naming `site` on a value count that is not the layout's. */
|
|
274
|
+
export function writeRecord(layout: readonly VertexAttribute[], fields: readonly AttributeAccess[], floats: Float32Array | null, i: number, values: ArrayLike<number>, site: string): void {
|
|
275
|
+
let n = layoutComponents(layout)
|
|
276
|
+
if (values.length !== n) throw new Error(site + ": a " + layoutKey(layout) + " record is " + n + " values, got " + values.length)
|
|
277
|
+
if (floats !== null) {
|
|
278
|
+
let at = i * n
|
|
279
|
+
for (let k = 0; k < n; k++) floats[at + k] = values[k]!
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
let j = 0
|
|
283
|
+
for (let f of fields) for (let k = 0; k < f.components; k++) f.set(i, k, values[j++]!)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** One record of `layout` encoded from `values`: the bytes a blank slot
|
|
287
|
+
* copies (a material's fresh style record). */
|
|
288
|
+
export function encodeRecord(layout: readonly VertexAttribute[], values: ArrayLike<number>, site: string): Uint8Array {
|
|
289
|
+
let buffer = new ArrayBuffer(layoutStride(layout))
|
|
290
|
+
let dv = new DataView(buffer)
|
|
291
|
+
let fields = layoutFields(layout, () => dv)
|
|
292
|
+
writeRecord(layout, fields, isFloatLayout(layout) ? new Float32Array(buffer) : null, 0, values, site)
|
|
293
|
+
return new Uint8Array(buffer)
|
|
294
|
+
}
|
package/src/window.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, flush } from "@solidjs/signals"
|
|
2
2
|
import { requestFrame, setPointerLock } from "flux:rendertree"
|
|
3
|
+
import { startTransitions } from "flux:spatial"
|
|
3
4
|
import { renderFrame } from "sol:render"
|
|
4
5
|
import { on, once } from "sol:events"
|
|
5
6
|
import { exit as nativeExit, background as nativeBackground, registerProtocolHandler as nativeRegisterProtocolHandler } from "sol:app"
|
|
@@ -470,7 +471,9 @@ export function onLayout(fn: () => void) {
|
|
|
470
471
|
// The last JS of a frame, in two passes. The plain handlers are the app's
|
|
471
472
|
// late update: they run once per frame after every onFrame callback and
|
|
472
473
|
// the reactive flush, so a follow or a fit reads the finished state of the
|
|
473
|
-
// frame (Unity's LateUpdate)
|
|
474
|
+
// frame (Unity's LateUpdate) - native motion stepped before the frame's
|
|
475
|
+
// callbacks and started after them (runFrame), so a pose read here is the
|
|
476
|
+
// one this frame draws. The publish handlers run after them, and
|
|
474
477
|
// once more after the post-layout handlers' flush (the one other JS entry
|
|
475
478
|
// ahead of the paint): an extension hands its pending writes to the engine
|
|
476
479
|
// there - a scene's record bytes and lights, a layer's style and pose
|
|
@@ -527,7 +530,11 @@ export interface BeforeRenderOptions {
|
|
|
527
530
|
* Calls `fn` right before every frame renders: after the frame's `onFrame`
|
|
528
531
|
* callbacks and the reactive flush, when the frame's state is final. The
|
|
529
532
|
* frame's late update: a camera follow, a fit to a measured box, anything
|
|
530
|
-
* that must see what every frame callback wrote and write once more.
|
|
533
|
+
* that must see what every frame callback wrote and write once more. The
|
|
534
|
+
* engine's own motion (a clip player, a node transition) has stepped to
|
|
535
|
+
* this frame's pose before the frame's callbacks and started what they
|
|
536
|
+
* declared (an enter from its `from`) after them, so a follow of an
|
|
537
|
+
* animated node reads where it is drawn, not where it was.
|
|
531
538
|
* Writes made here, to the engine or through signals, are in this frame:
|
|
532
539
|
* the pending reactive writes are flushed once every handler has run.
|
|
533
540
|
*
|
|
@@ -597,13 +604,15 @@ let backHandlers: ((e: BackEvent) => void)[] = []
|
|
|
597
604
|
|
|
598
605
|
/**
|
|
599
606
|
* Calls `fn` on the user's back intent (Android back button/gesture, the
|
|
600
|
-
* desktop dev chord). Call `e.preventDefault()` when
|
|
601
|
-
* navigation right now (close a modal, previous screen);
|
|
602
|
-
* event passes to the handler registered before this one,
|
|
603
|
-
* prevents it either, to the default action: what the
|
|
604
|
-
* root of an app - `background()` on Android, `exit()`
|
|
605
|
-
* without a handler get the platform's behavior, which is
|
|
606
|
-
* zero-effort default.
|
|
607
|
+
* pad's back button, the desktop dev chord). Call `e.preventDefault()` when
|
|
608
|
+
* back means in-app navigation right now (close a modal, previous screen);
|
|
609
|
+
* unprevented, the event passes to the handler registered before this one,
|
|
610
|
+
* and if none of them prevents it either, to the default action: what the
|
|
611
|
+
* platform does at the root of an app - `background()` on Android, `exit()`
|
|
612
|
+
* elsewhere. Apps without a handler get the platform's behavior, which is
|
|
613
|
+
* the correct zero-effort default. An Escape keydown that no key handler
|
|
614
|
+
* consumed (`stopPropagation`) runs the same stack without the default
|
|
615
|
+
* action: Escape closes the topmost overlay and never leaves the app.
|
|
607
616
|
*
|
|
608
617
|
* Handlers form a stack: the most recently registered runs first and the first
|
|
609
618
|
* to prevent ends the dispatch, so each screen or overlay owns one step of the
|
|
@@ -641,6 +650,7 @@ export function setWindowRoot(nodeId: number) {
|
|
|
641
650
|
export function attachWindow(nodeId: number) {
|
|
642
651
|
setWindowRoot(nodeId)
|
|
643
652
|
let unsubscribe: () => void = null!
|
|
653
|
+
let unsubFrameStart: () => void = null!
|
|
644
654
|
let unsubDown: () => void = null!
|
|
645
655
|
let unsubUp: () => void = null!
|
|
646
656
|
let unsubCancel: () => void = null!
|
|
@@ -665,8 +675,10 @@ export function attachWindow(nodeId: number) {
|
|
|
665
675
|
// (okf/done/onframe-tick-reset-on-reload.md). The callbacks stay
|
|
666
676
|
// registered and run on the first real render event, before the first
|
|
667
677
|
// paint with their writes applied.
|
|
678
|
+
// The frame's tick is stamped by "frameStart", ahead of this handler
|
|
679
|
+
// and of the engine's motion events (a clip's end, a settle), so a
|
|
680
|
+
// handler of those reads the frame's clock, not the last frame's.
|
|
668
681
|
function runFrame(t: number, frame: number, bootstrap = false) {
|
|
669
|
-
if (!bootstrap) latestTick = t
|
|
670
682
|
if (!bootstrap && animationFrames.size > 0) {
|
|
671
683
|
let frames = animationFrames
|
|
672
684
|
animationFrames = new Map()
|
|
@@ -679,6 +691,12 @@ export function attachWindow(nodeId: number) {
|
|
|
679
691
|
} catch (err) {
|
|
680
692
|
console.error("Error in reactive flush:", err)
|
|
681
693
|
}
|
|
694
|
+
// The frame's start pass: the native motion the callbacks and the
|
|
695
|
+
// flush declared (a node created with an enter, one let go of with an
|
|
696
|
+
// exit) starts here, where Unity runs its animation update - after
|
|
697
|
+
// Update, before LateUpdate - so the late pass reads the pose the
|
|
698
|
+
// frame draws. The engine steps running motion before the callbacks.
|
|
699
|
+
startTransitions()
|
|
682
700
|
runBeforeRender(bootstrap)
|
|
683
701
|
scanForOrphans(t)
|
|
684
702
|
renderFrame()
|
|
@@ -690,6 +708,9 @@ export function attachWindow(nodeId: number) {
|
|
|
690
708
|
if (hz > 0) refreshRate = hz
|
|
691
709
|
})
|
|
692
710
|
|
|
711
|
+
unsubFrameStart = on("frameStart", ({ time }: { time: number; frame: number }) => {
|
|
712
|
+
latestTick = time * 1000
|
|
713
|
+
})
|
|
693
714
|
unsubscribe = on("render", ({ time, frame }: { time: number; frame: number }) => {
|
|
694
715
|
runFrame(time * 1000, frame)
|
|
695
716
|
})
|
|
@@ -787,13 +808,33 @@ export function attachWindow(nodeId: number) {
|
|
|
787
808
|
}
|
|
788
809
|
})
|
|
789
810
|
|
|
811
|
+
// The back stack: top down, stopping at the first handler that prevents.
|
|
812
|
+
// The native triggers (the Android button, the pad's back, the dev chord)
|
|
813
|
+
// carry the platform default when none does; an unconsumed Escape runs
|
|
814
|
+
// the same stack without it, since Escape closes what is open and never
|
|
815
|
+
// leaves the app.
|
|
816
|
+
let dispatchBack = (platformDefault: boolean) => {
|
|
817
|
+
let prevented = false
|
|
818
|
+
let e: BackEvent = {
|
|
819
|
+
preventDefault: () => {
|
|
820
|
+
prevented = true
|
|
821
|
+
},
|
|
822
|
+
}
|
|
823
|
+
// Copy first: a handler may unregister (itself or others) mid-dispatch.
|
|
824
|
+
let stack = [...backHandlers]
|
|
825
|
+
for (let i = stack.length - 1; i >= 0 && !prevented; i--) stack[i]!(e)
|
|
826
|
+
if (!prevented && platformDefault) backDefault()
|
|
827
|
+
}
|
|
828
|
+
|
|
790
829
|
// Key events dispatch along the focused node's ancestor chain, leaf->root
|
|
791
830
|
// (the pointer bubbling contract), so a container hears keys from focused
|
|
792
831
|
// descendants and the window root hears everything: <window onKeyDown> is
|
|
793
832
|
// the app-global shortcut point. With nothing focused the path is the
|
|
794
833
|
// window root alone - key events are never dropped. The path is resolved
|
|
795
834
|
// at dispatch time from current focus (nothing to freeze: keyup follows
|
|
796
|
-
// focus, as in the DOM).
|
|
835
|
+
// focus, as in the DOM). An Escape keydown that reaches the end of the
|
|
836
|
+
// chain unconsumed is a back: the web's own default action for the key
|
|
837
|
+
// (closing a dialog), which a handler suppresses with stopPropagation.
|
|
797
838
|
let dispatchKey = (raw: any, handler: string) => {
|
|
798
839
|
let target = focusedNode() ?? windowRootId
|
|
799
840
|
let stopped = false
|
|
@@ -807,25 +848,14 @@ export function attachWindow(nodeId: number) {
|
|
|
807
848
|
getEventHandler(id, handler)?.(e)
|
|
808
849
|
if (stopped) break
|
|
809
850
|
}
|
|
851
|
+
if (!stopped && handler === "onKeyDown" && e.key === "Escape") dispatchBack(false)
|
|
810
852
|
}
|
|
811
853
|
|
|
812
854
|
unsubKeyDown = on("keydown", (raw: any) => dispatchKey(raw, "onKeyDown"))
|
|
813
855
|
|
|
814
856
|
unsubKeyUp = on("keyup", (raw: any) => dispatchKey(raw, "onKeyUp"))
|
|
815
857
|
|
|
816
|
-
unsubBack = on("back", () =>
|
|
817
|
-
let prevented = false
|
|
818
|
-
let e: BackEvent = {
|
|
819
|
-
preventDefault: () => {
|
|
820
|
-
prevented = true
|
|
821
|
-
},
|
|
822
|
-
}
|
|
823
|
-
// Copy first: a handler may unregister (itself or others) mid-dispatch.
|
|
824
|
-
// Top of the stack down, stopping as soon as one takes the event.
|
|
825
|
-
let stack = [...backHandlers]
|
|
826
|
-
for (let i = stack.length - 1; i >= 0 && !prevented; i--) stack[i]!(e)
|
|
827
|
-
if (!prevented) backDefault()
|
|
828
|
-
})
|
|
858
|
+
unsubBack = on("back", () => dispatchBack(true))
|
|
829
859
|
|
|
830
860
|
unsubTextInput = on("textInput", (e: any) => {
|
|
831
861
|
let id = focusedNode()
|
|
@@ -855,6 +885,7 @@ export function attachWindow(nodeId: number) {
|
|
|
855
885
|
onCleanup(() => {
|
|
856
886
|
setInterestRoot(null)
|
|
857
887
|
if (unsubscribe) unsubscribe()
|
|
888
|
+
if (unsubFrameStart) unsubFrameStart()
|
|
858
889
|
if (unsubDown) unsubDown()
|
|
859
890
|
if (unsubUp) unsubUp()
|
|
860
891
|
if (unsubCancel) unsubCancel()
|