@solidrt/core 0.0.39 → 0.0.41
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 +3 -2
- package/README.md +1 -1
- package/examples/README.md +9 -4
- package/examples/gpu-instancing.tsx +70 -0
- package/examples/gpu-particles.tsx +96 -0
- package/examples/gpu-pipeline.tsx +40 -37
- package/examples/gpu-raw-program.tsx +95 -0
- package/examples/gpu-shader.tsx +66 -24
- package/examples/gpu-texture-blend.tsx +79 -0
- package/examples/inline-image.tsx +1 -1
- package/examples/parse-svg.tsx +94 -0
- package/examples/text-import.tsx +29 -0
- package/examples/wave.glsl +15 -0
- package/examples/window-shader-history.tsx +23 -23
- package/examples/window-shader.tsx +30 -28
- package/jsx-runtime.d.ts +17 -9
- package/package.json +2 -2
- package/src/camera.ts +8 -4
- package/src/color.ts +14 -2
- package/src/gpu.ts +289 -90
- package/src/image.ts +12 -11
- package/src/index.ts +3 -0
- package/src/renderer.ts +9 -8
- package/src/runtime-modules.d.ts +19 -3
- package/src/speech-recognition.ts +2 -1
- package/src/svg.ts +71 -0
- package/src/types.d.ts +83 -40
- package/examples/svg.tsx +0 -49
package/src/gpu.ts
CHANGED
|
@@ -7,6 +7,40 @@
|
|
|
7
7
|
// to hold a params prop, e.g. a shader that only feeds another shader as a
|
|
8
8
|
// sampler2D input. The imperative primitives (uploadTexture, setShaderParams,
|
|
9
9
|
// destroyTexture, ...) live in the `flux:gpu` module.
|
|
10
|
+
//
|
|
11
|
+
// Sampling is a per-texture property declared at creation: `filter`
|
|
12
|
+
// ("linear" default | "nearest") and `wrap` ("clamp" default | "repeat") on
|
|
13
|
+
// every create* helper. One state for every consumer - `<texture>` display
|
|
14
|
+
// and shader sampling both follow it - so a nearest texture upscales with
|
|
15
|
+
// hard pixels everywhere (the retro/pixel-art path: render small, display
|
|
16
|
+
// big). No mipmaps exist.
|
|
17
|
+
//
|
|
18
|
+
// Combining several passes is a render-tree job, not a shader one: stack
|
|
19
|
+
// `<texture>` elements and set their `blendMode` (e.g. `blendMode="plus"` for
|
|
20
|
+
// an additive pass over a base pass) instead of writing a pass that samples
|
|
21
|
+
// both. WITHIN one pipeline draw, `blend: "add"` accumulates overlapping
|
|
22
|
+
// geometry additively (order-independent, no sorting); anything else draws
|
|
23
|
+
// with GL blending disabled and overwrites.
|
|
24
|
+
//
|
|
25
|
+
// The pixel contract. Three facts hold for every texture and target:
|
|
26
|
+
//
|
|
27
|
+
// - Clip space is y-down. `gl_Position` y = -1 is the top of the target, +1
|
|
28
|
+
// the bottom (GL's row 0 is clip y = -1, and Impeller samples row 0 as the
|
|
29
|
+
// top). A vertex stage carrying camera-up geometry must negate y, or fold
|
|
30
|
+
// the flip into its projection, or it draws upside down: Vulkan's
|
|
31
|
+
// convention, not desktop GL's. The fragment path absorbs the same flip
|
|
32
|
+
// already, so `vUV` is 0..1 with top-left origin and a fragment-only shader
|
|
33
|
+
// never sees it.
|
|
34
|
+
// - Color is premultiplied alpha. A target's RGB is expected already
|
|
35
|
+
// multiplied by its A - `vec4(rgb * a, a)`, not `vec4(rgb, a)`, which
|
|
36
|
+
// composites as opaque. That is what Impeller composites and what
|
|
37
|
+
// `<texture blendMode>` blends; `clearColor` is premultiplied too, so the
|
|
38
|
+
// default transparent black needs no thought.
|
|
39
|
+
// - Values are non-linear RGBA8, with no color-space concept. Every texture
|
|
40
|
+
// and target holds 8-bit RGBA UNORM exactly as written; nothing converts to
|
|
41
|
+
// or from linear light. `filter: "linear"` averages and `blend: "add"`
|
|
42
|
+
// accumulates non-linear values - the usual approximation, stated so
|
|
43
|
+
// shaders written today stay correct if a format vocabulary arrives.
|
|
10
44
|
|
|
11
45
|
import { createEffect, createSignal, getOwner, onCleanup, untrack } from "@solidjs/signals"
|
|
12
46
|
import * as gpu from "flux:gpu"
|
|
@@ -16,7 +50,21 @@ import * as gpu from "flux:gpu"
|
|
|
16
50
|
// signal changes inside a long-lived component, handed across owners, ...).
|
|
17
51
|
// Without it, each rebuild would stack another onCleanup on the component
|
|
18
52
|
// owner: a leak until unmount, then a double-free against manual destroys.
|
|
19
|
-
|
|
53
|
+
// `label` is a free-form debug name (WebGPU's label): surfaced by the dev
|
|
54
|
+
// tooling's GPU inventory and engine log messages, never interpreted, kept
|
|
55
|
+
// across id-stable resizes.
|
|
56
|
+
export type CreateOptions = { manual?: boolean; label?: string }
|
|
57
|
+
|
|
58
|
+
// Sampling options every texture-producing create* helper accepts, applied at
|
|
59
|
+
// creation as a property of the texture id (there is no set-sampler-later).
|
|
60
|
+
export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode }
|
|
61
|
+
export type { FilterMode, WrapMode } from "flux:gpu"
|
|
62
|
+
|
|
63
|
+
// The branded id types, one per id space (see flux:gpu): plain numbers at
|
|
64
|
+
// runtime, distinct types to the checker, so a cross-space slip like
|
|
65
|
+
// destroyBuffer(textureId) fails to compile. Exported so apps can annotate
|
|
66
|
+
// storage (`let ids: TextureId[]`).
|
|
67
|
+
export type { BufferId, ProgramId, RenderPipelineId, ShaderStageId, TextureId } from "flux:gpu"
|
|
20
68
|
|
|
21
69
|
// Re-exported so callers that depend on @solidrt/core -- like @solidrt/components
|
|
22
70
|
// -- need not import flux directly: destroyTexture for the manual-cleanup path
|
|
@@ -38,23 +86,70 @@ export {
|
|
|
38
86
|
uploadTexture,
|
|
39
87
|
} from "flux:gpu"
|
|
40
88
|
|
|
41
|
-
// Pipeline plumbing re-exported raw:
|
|
42
|
-
// its buffer gained or lost dynamic
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
89
|
+
// Pipeline plumbing re-exported raw: setDraw re-renders a pipeline with an
|
|
90
|
+
// updated draw range (vertexCount after its buffer gained or lost dynamic
|
|
91
|
+
// geometry, firstVertex for a different window of a shared buffer,
|
|
92
|
+
// instanceCount for an instanced population; absent keys keep their current
|
|
93
|
+
// value, like params); destroyBuffer is the manual
|
|
94
|
+
// cleanup path for buffers created outside a reactive scope. renderTarget is
|
|
95
|
+
// the explicit render verb for `render: "manual"` targets - targets whose
|
|
96
|
+
// pass is state (accumulation, feedback) rather than a pure function of its
|
|
97
|
+
// inputs, which the runtime therefore never renders on its own; the app
|
|
98
|
+
// steps them, usually from onFrame. (`render: "manual"` is the render mode;
|
|
99
|
+
// the unrelated `manual: true` create option is the lifetime opt-out above.)
|
|
100
|
+
// copyTexture overwrites a manual target with another texture's pixels
|
|
101
|
+
// GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
|
|
102
|
+
// ping-pong buffer, reset state to a known image.
|
|
103
|
+
export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
|
|
104
|
+
export type { BlendMode, DrawRange, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
|
|
105
|
+
|
|
106
|
+
// The device ceilings (max texture/target size, sampler inputs per pass,
|
|
107
|
+
// vertex attributes per pipeline), queried once at startup. Creates and binds
|
|
108
|
+
// validate against them and throw naming the limit; read these to size within
|
|
109
|
+
// the device instead (e.g. clamp a supersampled target to maxTextureSize).
|
|
110
|
+
export { limits } from "flux:gpu"
|
|
46
111
|
|
|
47
112
|
// The raw shading layer, re-exported as-is - no reactive wrapper, the app
|
|
48
113
|
// owns these lifetimes. compileShader compiles one stage from complete GLSL
|
|
49
114
|
// ES (or with the standard header via { header: true }); linkProgram links a
|
|
50
|
-
// vertex and a fragment stage into a program handle
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
115
|
+
// vertex and a fragment stage into a program handle; createRenderPipeline
|
|
116
|
+
// pairs a program with draw state (vertex layout, topology, blend, depth -
|
|
117
|
+
// how it draws) into a pipeline handle that backs any number of
|
|
118
|
+
// createShaderTarget calls (and compiles nothing per pipeline or target);
|
|
119
|
+
// destroyShader / destroyProgram / destroyRenderPipeline free by id space,
|
|
120
|
+
// any order safe against live users. createShaderTexture/createPipelineTexture
|
|
121
|
+
// remain the fused conveniences on top.
|
|
122
|
+
export {
|
|
123
|
+
compileShader,
|
|
124
|
+
createRenderPipeline,
|
|
125
|
+
destroyProgram,
|
|
126
|
+
destroyRenderPipeline,
|
|
127
|
+
destroyShader,
|
|
128
|
+
linkProgram,
|
|
129
|
+
} from "flux:gpu"
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Tags an inline GLSL source, returning it unchanged. Shaders small enough to
|
|
133
|
+
* belong beside the code that uses them stay in the file; the tag is what makes
|
|
134
|
+
* them legible there, because editors highlight GLSL inside a template literal
|
|
135
|
+
* only when a known tag marks it (the name matters - `glsl` is the one the
|
|
136
|
+
* grammars look for).
|
|
137
|
+
*
|
|
138
|
+
* Interpolated values are stringified verbatim, with no GLSL-aware formatting:
|
|
139
|
+
* `${2}` splices in the int literal `2`, which will not assign to a float. Pass
|
|
140
|
+
* anything that varies as a uniform instead of building it into the source.
|
|
141
|
+
*
|
|
142
|
+
* Raw semantics, so backslashes reach the compiler as written: the GLSL
|
|
143
|
+
* preprocessor continues a line with a trailing `\`, which a cooked template
|
|
144
|
+
* would reject as an invalid escape and silently pass through as `undefined`.
|
|
145
|
+
*/
|
|
146
|
+
export let glsl = String.raw
|
|
55
147
|
|
|
56
148
|
// captureSnapshot renders a node to a texture and readTexture reads any
|
|
57
|
-
// texture's bytes back.
|
|
149
|
+
// texture's bytes back. A laid-out node captures its layout box; a `d-*` node
|
|
150
|
+
// captures its painted box - its own w/h when set, else the nearest laid-out
|
|
151
|
+
// ancestor's box, its x/y offset mapped to the texture origin. Re-exported raw
|
|
152
|
+
// (no reactive auto-cleanup wrapper):
|
|
58
153
|
// captureSnapshot resolves asynchronously, by which point the reactive owner is
|
|
59
154
|
// no longer current, so the caller owns the returned id and frees it with
|
|
60
155
|
// destroyTexture (as with any texture created after an await).
|
|
@@ -81,8 +176,13 @@ export { captureSnapshot, readTexture } from "flux:gpu"
|
|
|
81
176
|
* yourself. Pass `{ manual: true }` to skip the auto-free and own the
|
|
82
177
|
* disposal yourself even inside a reactive scope.
|
|
83
178
|
*/
|
|
84
|
-
export function createTexture(
|
|
85
|
-
|
|
179
|
+
export function createTexture(
|
|
180
|
+
data: Uint8Array,
|
|
181
|
+
width: number,
|
|
182
|
+
height: number,
|
|
183
|
+
opts?: CreateOptions & SamplerOptions,
|
|
184
|
+
): gpu.TextureId {
|
|
185
|
+
let id = gpu.createTexture(data, width, height, opts)
|
|
86
186
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
|
87
187
|
return id
|
|
88
188
|
}
|
|
@@ -96,8 +196,13 @@ export function createTexture(data: Uint8Array, width: number, height: number, o
|
|
|
96
196
|
* outside a reactive scope you must call `destroyTexture` (from flux:gpu)
|
|
97
197
|
* yourself.
|
|
98
198
|
*/
|
|
99
|
-
export function createMutableTexture(
|
|
100
|
-
|
|
199
|
+
export function createMutableTexture(
|
|
200
|
+
data: Uint8Array,
|
|
201
|
+
width: number,
|
|
202
|
+
height: number,
|
|
203
|
+
opts?: CreateOptions & SamplerOptions,
|
|
204
|
+
): gpu.TextureId {
|
|
205
|
+
let id = gpu.createMutableTexture(data, width, height, opts)
|
|
101
206
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
|
102
207
|
return id
|
|
103
208
|
}
|
|
@@ -105,78 +210,122 @@ export function createMutableTexture(data: Uint8Array, width: number, height: nu
|
|
|
105
210
|
/**
|
|
106
211
|
* Compiles a GLSL ES 3.00 fragment shader and renders it into a texture,
|
|
107
212
|
* returning the texture id (usable anywhere a normal texture id is, e.g.
|
|
108
|
-
* `<texture src>`)
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* (
|
|
114
|
-
*
|
|
213
|
+
* `<texture src>`) - hence the name: what comes back is a texture, not a
|
|
214
|
+
* shader object. The fragment body may reference `vUV` (0..1, top-left
|
|
215
|
+
* origin), `iResolution`, and any uniform it declares (`float`/`int`
|
|
216
|
+
* scalars from a number, `vec2`/`vec3`/`vec4`/`mat4` from a flat number
|
|
217
|
+
* array); drive their values with `<texture src={id} params={{...}} />`
|
|
218
|
+
* (preferred) or, when there is no `<texture>` element for it, imperatively
|
|
219
|
+
* with `setShaderParams`. `params` is its own argument - it seeds the same
|
|
220
|
+
* live channel those two drive - and takes `null` (or nothing) for a shader
|
|
221
|
+
* without uniforms. A time-driven shader declares its own time uniform
|
|
222
|
+
* (`uniform float uTime;`) and the app drives it like any other value.
|
|
223
|
+
* `opts.textures` binds each declared `uniform sampler2D` to an existing texture id
|
|
224
|
+
* (e.g. a camera or decoded image, or another shader/pipeline target) so the
|
|
225
|
+
* shader can read it; bound inputs are live dependencies, so the shader
|
|
226
|
+
* re-renders whenever a source changes - including a sampled target
|
|
227
|
+
* re-rendering, transitively through chains. Frees the
|
|
115
228
|
* texture and shader program when the reactive owner is disposed (opt out
|
|
116
229
|
* with `{ manual: true }`); create outside any reactive scope for
|
|
117
230
|
* app-lifetime shaders. For a shader whose source or inputs change
|
|
118
|
-
* reactively, use {@link
|
|
231
|
+
* reactively, use {@link createShaderTextureMemo} instead.
|
|
232
|
+
*
|
|
233
|
+
* That preamble (`#version 300 es`, precision, `vUV`, `iResolution`,
|
|
234
|
+
* `fragColor`) is injected only into sources that do not declare their own
|
|
235
|
+
* `#version` line, and declares exactly what the runtime provides - nothing
|
|
236
|
+
* app-driven. A source starting with `#version 300 es` compiles exactly
|
|
237
|
+
* as written, so a shader carrying its own uniform names - one ported from
|
|
238
|
+
* elsewhere - runs unchanged here without dropping to compileShader /
|
|
239
|
+
* linkProgram. The built-in vertex stage still supplies `vUV`; declare
|
|
240
|
+
* `in vec2 vUV;` yourself to read it.
|
|
119
241
|
*/
|
|
120
|
-
export function
|
|
242
|
+
export function createShaderTexture(
|
|
121
243
|
fragmentSrc: string,
|
|
122
244
|
width: number,
|
|
123
245
|
height: number,
|
|
124
|
-
params?:
|
|
125
|
-
textures?: Record<string,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
let id = gpu.createShader(fragmentSrc, width, height, params, textures)
|
|
246
|
+
params?: gpu.ShaderParams | null,
|
|
247
|
+
opts?: CreateOptions & SamplerOptions & { textures?: Record<string, gpu.TextureId> },
|
|
248
|
+
): gpu.TextureId {
|
|
249
|
+
let id = gpu.createShaderTexture(fragmentSrc, width, height, params, opts)
|
|
129
250
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
|
130
251
|
return id
|
|
131
252
|
}
|
|
132
253
|
|
|
133
254
|
/**
|
|
134
|
-
* Creates a render target over a
|
|
135
|
-
* once, returning the texture id (usable anywhere a normal
|
|
136
|
-
* e.g. `<texture src>`; resize with `setShaderSize`, drive
|
|
137
|
-
* `<texture params>` or `setShaderParams`). Many targets may
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
255
|
+
* Creates a render target over a pipeline from `createRenderPipeline` and
|
|
256
|
+
* renders it once, returning the texture id (usable anywhere a normal
|
|
257
|
+
* texture id is, e.g. `<texture src>`; resize with `setShaderSize`, drive
|
|
258
|
+
* uniforms with `<texture params>` or `setShaderParams`). Many targets may
|
|
259
|
+
* share one pipeline, and creating a target compiles nothing. The target
|
|
260
|
+
* brings the per-target half: size, the concrete vertex `buffer` the
|
|
261
|
+
* pipeline's attribute layout describes, the draw range (`vertexCount`
|
|
262
|
+
* defaults to the rest of the buffer from `firstVertex` on, `instanceCount`
|
|
263
|
+
* repeats it as instances told apart by `gl_InstanceID`; a fullscreen pass
|
|
264
|
+
* over an attributeless pipeline is `{ vertexCount: 3 }` with a
|
|
265
|
+
* covering-triangle vertex stage), uniforms, and
|
|
266
|
+
* `clearColor`. Draw state (`attributes`, `topology`, `blend`, `depth`,
|
|
267
|
+
* `depthWrite`) lives on the pipeline and throws here. Frees the target when
|
|
268
|
+
* the reactive owner is disposed (opt out with `opts.manual`); the pipeline
|
|
269
|
+
* is yours and outlives it.
|
|
270
|
+
*
|
|
271
|
+
* `render: "manual"` makes it a manual target: the runtime never renders it
|
|
272
|
+
* (it starts cleared to `clearColor`), only an explicit `renderTarget(id)`
|
|
273
|
+
* does, in call order - which is what legalizes feedback state stepped by
|
|
274
|
+
* the app. `loadOp: "load"` (manual-only, throws otherwise) keeps the
|
|
275
|
+
* previous contents under each draw - single-target accumulation - while
|
|
276
|
+
* the default `"clear"` clears to `clearColor` per render; state that must
|
|
277
|
+
* read its own pixels (decay, blur, simulation) still ping-pongs across two
|
|
278
|
+
* manual targets, and `copyTexture` seeds either shape. Distinct from the
|
|
279
|
+
* `manual` lifetime option: `render` is who renders, `manual` is who frees.
|
|
143
280
|
*/
|
|
144
281
|
export function createShaderTarget(
|
|
145
|
-
|
|
282
|
+
pipeline: gpu.RenderPipelineId,
|
|
146
283
|
width: number,
|
|
147
284
|
height: number,
|
|
285
|
+
params?: gpu.ShaderParams | null,
|
|
148
286
|
opts?: {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
attributes?: gpu.VertexAttribute[]
|
|
152
|
-
buffer?: number
|
|
153
|
-
topology?: gpu.Topology
|
|
154
|
-
vertexCount?: number
|
|
155
|
-
depth?: boolean
|
|
287
|
+
textures?: Record<string, gpu.TextureId>
|
|
288
|
+
buffer?: gpu.BufferId
|
|
156
289
|
clearColor?: [number, number, number, number]
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
290
|
+
render?: "auto" | "manual"
|
|
291
|
+
loadOp?: "clear" | "load"
|
|
292
|
+
} & gpu.DrawRange &
|
|
293
|
+
CreateOptions &
|
|
294
|
+
SamplerOptions,
|
|
295
|
+
): gpu.TextureId {
|
|
296
|
+
let id = gpu.createShaderTarget(pipeline, width, height, params, opts)
|
|
160
297
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
|
161
298
|
return id
|
|
162
299
|
}
|
|
163
300
|
|
|
164
|
-
/** The reactive shader description `
|
|
301
|
+
/** The reactive shader description `createShaderTextureMemo` builds from.
|
|
302
|
+
* Sampling (`filter`/`wrap`) is creation-time state, so changing it rebuilds
|
|
303
|
+
* at a fresh id, like a fragment-source or sampler-binding change. */
|
|
165
304
|
export type ShaderSpec = {
|
|
166
305
|
fragmentSrc: string
|
|
167
306
|
width: number
|
|
168
307
|
height: number
|
|
169
|
-
params?:
|
|
170
|
-
textures?: Record<string,
|
|
308
|
+
params?: gpu.ShaderParams
|
|
309
|
+
textures?: Record<string, gpu.TextureId>
|
|
310
|
+
} & SamplerOptions
|
|
311
|
+
|
|
312
|
+
// Shallow name->value equality for params/textures records; treats undefined
|
|
313
|
+
// as the empty record. A param value may be a number or a flat number array
|
|
314
|
+
// (typed uniforms), so arrays compare elementwise.
|
|
315
|
+
function sameValue(a: number | number[] | undefined, b: number | number[] | undefined): boolean {
|
|
316
|
+
if (a === b) return true
|
|
317
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
|
318
|
+
return a.length === b.length && a.every((v, i) => v === b[i])
|
|
171
319
|
}
|
|
172
320
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
321
|
+
function sameRecord(
|
|
322
|
+
a: Record<string, number | number[]> | undefined,
|
|
323
|
+
b: Record<string, number | number[]> | undefined,
|
|
324
|
+
): boolean {
|
|
176
325
|
if (a === b) return true
|
|
177
326
|
let ka = a ? Object.keys(a) : []
|
|
178
327
|
let kb = b ? Object.keys(b) : []
|
|
179
|
-
return ka.length === kb.length && ka.every(k => a![k]
|
|
328
|
+
return ka.length === kb.length && ka.every(k => sameValue(a![k], b![k]))
|
|
180
329
|
}
|
|
181
330
|
|
|
182
331
|
/**
|
|
@@ -191,28 +340,57 @@ function sameRecord(a: Record<string, number> | undefined, b: Record<string, num
|
|
|
191
340
|
* so the swap never paints a blank frame. The current id is freed when the
|
|
192
341
|
* owning scope is disposed. Data textures need no analog: `uploadTexture` and
|
|
193
342
|
* `resizeTexture` already cover their reactive changes id-stably.
|
|
343
|
+
*
|
|
344
|
+
* `onError` makes a failed rebuild survivable. Without it a shader that does
|
|
345
|
+
* not compile throws from inside the effect, where no caller can catch it;
|
|
346
|
+
* with it the error is handed to you and the last shader that DID compile
|
|
347
|
+
* stays current - id, size, params and accessor all unchanged - so the app
|
|
348
|
+
* keeps drawing the previous frame's shader instead of tearing down. That is
|
|
349
|
+
* the normal case whenever the source is not known-good: a shader editor, live
|
|
350
|
+
* coding, or a dialect ported from elsewhere. The initial compile is not
|
|
351
|
+
* covered: it throws at the call site, where an ordinary try/catch works and
|
|
352
|
+
* there is no previous shader to fall back to.
|
|
194
353
|
*/
|
|
195
|
-
export function
|
|
354
|
+
export function createShaderTextureMemo(
|
|
355
|
+
spec: () => ShaderSpec,
|
|
356
|
+
opts?: { onError?: (error: unknown) => void },
|
|
357
|
+
): () => gpu.TextureId {
|
|
358
|
+
let make = (s: ShaderSpec) =>
|
|
359
|
+
gpu.createShaderTexture(s.fragmentSrc, s.width, s.height, s.params, { textures: s.textures, filter: s.filter, wrap: s.wrap })
|
|
196
360
|
let current = untrack(spec)
|
|
197
|
-
let currentId =
|
|
361
|
+
let currentId = make(current)
|
|
198
362
|
let [id, setId] = createSignal(currentId)
|
|
199
363
|
createEffect(spec, next => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
364
|
+
try {
|
|
365
|
+
if (
|
|
366
|
+
next.fragmentSrc === current.fragmentSrc &&
|
|
367
|
+
sameRecord(next.textures, current.textures) &&
|
|
368
|
+
next.filter === current.filter &&
|
|
369
|
+
next.wrap === current.wrap
|
|
370
|
+
) {
|
|
371
|
+
// Program and inputs unchanged: mutate in place, the id stays stable.
|
|
372
|
+
if (next.width !== current.width || next.height !== current.height) {
|
|
373
|
+
gpu.setShaderSize(currentId, next.width, next.height)
|
|
374
|
+
}
|
|
375
|
+
if (!sameRecord(next.params, current.params) && next.params) {
|
|
376
|
+
gpu.setShaderParams(currentId, next.params)
|
|
377
|
+
}
|
|
378
|
+
current = next
|
|
379
|
+
return
|
|
207
380
|
}
|
|
381
|
+
// Compile before touching any state: a throw here must leave `current`,
|
|
382
|
+
// `currentId` and the accessor all still pointing at the last shader
|
|
383
|
+
// that worked, which is what makes onError's keep-last-good real.
|
|
384
|
+
let rebuilt = make(next)
|
|
385
|
+
let old = currentId
|
|
208
386
|
current = next
|
|
209
|
-
|
|
387
|
+
currentId = rebuilt
|
|
388
|
+
setId(rebuilt)
|
|
389
|
+
gpu.destroyTexture(old)
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (!opts?.onError) throw error
|
|
392
|
+
opts.onError(error)
|
|
210
393
|
}
|
|
211
|
-
let old = currentId
|
|
212
|
-
current = next
|
|
213
|
-
currentId = gpu.createShader(next.fragmentSrc, next.width, next.height, next.params, next.textures)
|
|
214
|
-
setId(currentId)
|
|
215
|
-
gpu.destroyTexture(old)
|
|
216
394
|
})
|
|
217
395
|
if (getOwner()) onCleanup(() => gpu.destroyTexture(currentId))
|
|
218
396
|
return id
|
|
@@ -229,35 +407,56 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
|
229
407
|
/**
|
|
230
408
|
* Compiles a GLSL ES 3.00 vertex+fragment pipeline and renders it into a
|
|
231
409
|
* texture, returning the texture id (usable anywhere a normal texture id is,
|
|
232
|
-
* e.g. `<texture src>`)
|
|
410
|
+
* e.g. `<texture src>`) - named, like `createShaderTexture`, for what comes
|
|
411
|
+
* back. Unlike `createShaderTexture` the vertex stage is yours:
|
|
233
412
|
* declare `in` attributes matching `opts.attributes` (one interleaved vertex
|
|
234
413
|
* in `opts.buffer`, a {@link createBuffer} id) and your own varyings toward
|
|
235
|
-
* the fragment stage.
|
|
236
|
-
*
|
|
237
|
-
*
|
|
414
|
+
* the fragment stage. Clip space is y-down: `gl_Position` y = -1 is the top
|
|
415
|
+
* row of the target and +1 the bottom, so camera-up geometry must negate y
|
|
416
|
+
* (or fold the flip into its projection) to display up. Both sources may
|
|
417
|
+
* reference `iResolution` and any uniform they declare (`float`/`int`
|
|
418
|
+
* scalars from a number, `vec2`/`vec3`/`vec4`/`mat4` from a flat number
|
|
419
|
+
* array); drive values with `<texture src={id} params={{...}} />` or
|
|
420
|
+
* `setShaderParams`, exactly like a fragment shader.
|
|
238
421
|
* `opts.depth` attaches a private depth buffer (cleared + tested per render);
|
|
239
|
-
* `opts.
|
|
240
|
-
*
|
|
422
|
+
* `opts.depthWrite: false` (requires depth) keeps the test but stops the
|
|
423
|
+
* draw from writing depth. `opts.blend: "add"` makes the draw accumulate
|
|
424
|
+
* overlapping geometry additively (order-independent, no sorting) instead of
|
|
425
|
+
* overwriting; a depth-tested additive pass is `{ depth: true, blend: "add",
|
|
426
|
+
* depthWrite: false }` - each option only does what it says, neither implies
|
|
427
|
+
* the other. The draw range (`firstVertex`, `vertexCount`, `instanceCount` -
|
|
428
|
+
* see DrawRange) defaults to the whole buffer drawn once and can be changed
|
|
429
|
+
* later with `setDraw`; `instanceCount` is the standard answer to particles
|
|
430
|
+
* and repeated meshes, N copies of the range told apart by `gl_InstanceID`
|
|
431
|
+
* in the vertex stage. `opts.render: "manual"` and
|
|
432
|
+
* `opts.loadOp` behave exactly as on {@link createShaderTarget}: step the
|
|
433
|
+
* target with `renderTarget(id)`, and `loadOp: "load"` (manual-only) keeps
|
|
434
|
+
* the previous contents under each draw. Frees the texture and GL program when the reactive
|
|
241
435
|
* owner is disposed (opt out with `opts.manual`); create outside any reactive
|
|
242
436
|
* scope for app-lifetime pipelines.
|
|
243
437
|
*/
|
|
244
|
-
export function
|
|
438
|
+
export function createPipelineTexture(
|
|
245
439
|
vertexSrc: string,
|
|
246
440
|
fragmentSrc: string,
|
|
247
441
|
width: number,
|
|
248
442
|
height: number,
|
|
443
|
+
params?: gpu.ShaderParams | null,
|
|
249
444
|
opts?: {
|
|
250
|
-
|
|
251
|
-
textures?: Record<string, number>
|
|
445
|
+
textures?: Record<string, gpu.TextureId>
|
|
252
446
|
attributes?: gpu.VertexAttribute[]
|
|
253
|
-
buffer?:
|
|
447
|
+
buffer?: gpu.BufferId
|
|
254
448
|
topology?: gpu.Topology
|
|
255
|
-
vertexCount?: number
|
|
256
449
|
depth?: boolean
|
|
450
|
+
depthWrite?: boolean
|
|
451
|
+
blend?: gpu.BlendMode
|
|
257
452
|
clearColor?: [number, number, number, number]
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
453
|
+
render?: "auto" | "manual"
|
|
454
|
+
loadOp?: "clear" | "load"
|
|
455
|
+
} & gpu.DrawRange &
|
|
456
|
+
CreateOptions &
|
|
457
|
+
SamplerOptions,
|
|
458
|
+
): gpu.TextureId {
|
|
459
|
+
let id = gpu.createPipelineTexture(vertexSrc, fragmentSrc, width, height, params, opts)
|
|
261
460
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
|
262
461
|
return id
|
|
263
462
|
}
|
|
@@ -269,10 +468,10 @@ export function createPipeline(
|
|
|
269
468
|
* creation, so reserve room up front for dynamic geometry. Freed automatically
|
|
270
469
|
* when the reactive owner is disposed (opt out with `{ manual: true }`);
|
|
271
470
|
* created outside a reactive scope you must call `destroyBuffer` yourself.
|
|
272
|
-
*
|
|
471
|
+
* (Destruction order relative to pipelines does not matter.)
|
|
273
472
|
*/
|
|
274
|
-
export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateOptions):
|
|
275
|
-
let id = gpu.createBuffer(toUint8(data))
|
|
473
|
+
export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateOptions): gpu.BufferId {
|
|
474
|
+
let id = gpu.createBuffer(toUint8(data), opts)
|
|
276
475
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyBuffer(id))
|
|
277
476
|
return id
|
|
278
477
|
}
|
|
@@ -282,6 +481,6 @@ export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateO
|
|
|
282
481
|
* pipeline drawing from the buffer re-renders with its last-applied params,
|
|
283
482
|
* so geometry-only changes reach the screen without a params update.
|
|
284
483
|
*/
|
|
285
|
-
export function writeBuffer(id:
|
|
484
|
+
export function writeBuffer(id: gpu.BufferId, data: ArrayBuffer | ArrayBufferView, byteOffset?: number): void {
|
|
286
485
|
gpu.writeBuffer(id, toUint8(data), byteOffset)
|
|
287
486
|
}
|
package/src/image.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// CPU image codec plus the reactive load-and-upload convenience. decodeImage is
|
|
2
2
|
// the raw primitive (no GPU involved); createImage is the owner-aware layer on
|
|
3
3
|
// top that fetches/decodes/uploads for you and swaps the texture when the source
|
|
4
|
-
// changes - the same relationship createTexture/
|
|
4
|
+
// changes - the same relationship createTexture/createShaderTexture have to
|
|
5
|
+
// flux:gpu.
|
|
5
6
|
|
|
6
7
|
import { createMemo, onCleanup } from "@solidjs/signals"
|
|
7
|
-
import { createTexture, destroyTexture } from "./gpu"
|
|
8
|
+
import { createTexture, destroyTexture, type TextureId } from "./gpu"
|
|
8
9
|
|
|
9
10
|
export type DecodedImage = {
|
|
10
11
|
data: Uint8Array
|
|
@@ -32,13 +33,13 @@ export type ImageSource = string | Uint8Array
|
|
|
32
33
|
// Uint8Array sources bypass all of this: no key, per-mount texture.
|
|
33
34
|
type ImageEntry = {
|
|
34
35
|
refs: number
|
|
35
|
-
texture:
|
|
36
|
-
promise: Promise<
|
|
36
|
+
texture: TextureId | undefined
|
|
37
|
+
promise: Promise<TextureId>
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
let imageCache = new Map<string, ImageEntry>()
|
|
40
41
|
|
|
41
|
-
async function loadImage(url: string): Promise<
|
|
42
|
+
async function loadImage(url: string): Promise<TextureId> {
|
|
42
43
|
// Images are assets: cache to disk, no freshness. Use a versioned URL (or
|
|
43
44
|
// fetch + decodeImage manually) when a URL's content must be re-checked.
|
|
44
45
|
let res = await fetch(url, { cache: "force-cache" })
|
|
@@ -56,7 +57,7 @@ async function loadImage(url: string): Promise<number> {
|
|
|
56
57
|
function acquireImage(url: string): ImageEntry {
|
|
57
58
|
let entry = imageCache.get(url)
|
|
58
59
|
if (!entry) {
|
|
59
|
-
let e: ImageEntry = { refs: 0, texture:
|
|
60
|
+
let e: ImageEntry = { refs: 0, texture: undefined, promise: undefined as never }
|
|
60
61
|
e.promise = loadImage(url).then(
|
|
61
62
|
id => {
|
|
62
63
|
// Everyone released while the load was in flight: nothing owns the
|
|
@@ -91,7 +92,7 @@ function releaseImage(url: string): void {
|
|
|
91
92
|
if (!entry) return
|
|
92
93
|
entry.refs--
|
|
93
94
|
if (entry.refs > 0) return
|
|
94
|
-
if (entry.texture
|
|
95
|
+
if (entry.texture !== undefined) {
|
|
95
96
|
destroyTexture(entry.texture)
|
|
96
97
|
imageCache.delete(url)
|
|
97
98
|
}
|
|
@@ -122,10 +123,10 @@ function releaseImage(url: string): void {
|
|
|
122
123
|
* `createImage` earns its async only for a fetched string URL or a reactive
|
|
123
124
|
* source.
|
|
124
125
|
*/
|
|
125
|
-
export function createImage(src: ImageSource | (() => ImageSource)): () =>
|
|
126
|
+
export function createImage(src: ImageSource | (() => ImageSource)): () => TextureId {
|
|
126
127
|
let getSrc = typeof src === "function" ? src : () => src
|
|
127
128
|
|
|
128
|
-
return createMemo<
|
|
129
|
+
return createMemo<TextureId>(async () => {
|
|
129
130
|
let source = getSrc()
|
|
130
131
|
|
|
131
132
|
if (typeof source === "string") {
|
|
@@ -138,9 +139,9 @@ export function createImage(src: ImageSource | (() => ImageSource)): () => numbe
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
// Byte sources decode and upload synchronously; this run owns the texture.
|
|
141
|
-
let holder = { id:
|
|
142
|
+
let holder: { id: TextureId | undefined } = { id: undefined }
|
|
142
143
|
onCleanup(() => {
|
|
143
|
-
if (holder.id
|
|
144
|
+
if (holder.id !== undefined) destroyTexture(holder.id)
|
|
144
145
|
})
|
|
145
146
|
let decoded: DecodedImage
|
|
146
147
|
try {
|
package/src/index.ts
CHANGED
|
@@ -13,8 +13,11 @@ export type { GamepadState } from "./gamepad"
|
|
|
13
13
|
export { capabilities } from "./capabilities"
|
|
14
14
|
export type { Capabilities, WindowSizeClass } from "./capabilities"
|
|
15
15
|
export { createTexture } from "./gpu"
|
|
16
|
+
export type { TextureId } from "./gpu"
|
|
16
17
|
export { createImage, decodeImage } from "./image"
|
|
17
18
|
export type { DecodedImage, ImageSource } from "./image"
|
|
19
|
+
export { parseSvg, svg } from "./svg"
|
|
20
|
+
export type { SvgDocument, SvgDraw } from "./svg"
|
|
18
21
|
export { createScroll } from "./scroll"
|
|
19
22
|
export type { Scroll, ScrollAxis, ScrollOffset, ScrollOptions } from "./scroll"
|
|
20
23
|
export type {
|
package/src/renderer.ts
CHANGED
|
@@ -129,21 +129,22 @@ export function scanForOrphans(now: number): void {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// A property the native tree rejected must not take down the reactive system:
|
|
132
|
-
// a typo'd
|
|
133
|
-
// element kind + property with a stack (the dev server remaps
|
|
134
|
-
// the .tsx source), then ignore further writes of the same pair.
|
|
135
|
-
let
|
|
132
|
+
// a typo'd, not-yet-implemented, or detached-only prop poisons only itself.
|
|
133
|
+
// Warn once per element kind + property with a stack (the dev server remaps
|
|
134
|
+
// its frames to the .tsx source), then ignore further writes of the same pair.
|
|
135
|
+
let warnedRejectedProps = new Set<string>()
|
|
136
136
|
|
|
137
137
|
function setTreeProperty(node: ProxyNode, name: string, value: unknown): void {
|
|
138
138
|
try {
|
|
139
139
|
tree.setProperty(node.id, name, value)
|
|
140
140
|
} catch (e) {
|
|
141
|
-
|
|
141
|
+
let message = String(e)
|
|
142
|
+
if (!message.includes("unknown property") && !message.includes("detached-only")) throw e
|
|
142
143
|
let key = node.elementType + "." + name
|
|
143
|
-
if (
|
|
144
|
-
|
|
144
|
+
if (warnedRejectedProps.has(key)) return
|
|
145
|
+
warnedRejectedProps.add(key)
|
|
145
146
|
let stack = new Error().stack ?? ""
|
|
146
|
-
console.warn(`Ignoring
|
|
147
|
+
console.warn(`Ignoring property '${name}' on <${node.elementType}>: ${message}\n${stack}`)
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
|