@solidrt/core 0.0.64 → 0.0.65
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 +92 -9
- package/README.md +1 -1
- package/agents/performance.md +7 -2
- package/docs/index.md +9 -0
- package/package.json +7 -5
- package/src/core.ts +6 -1
- package/src/double-tap.ts +2 -2
- package/src/gamepad.ts +32 -1
- package/src/gpu.ts +6 -5
- package/src/image.ts +10 -2
- package/src/index.ts +6 -4
- package/src/input-gamepad-device.ts +38 -14
- package/src/input-gamepad.ts +6 -4
- package/src/input-keyboard.ts +11 -2
- package/src/input-map.ts +12 -1
- package/src/input-pointer.ts +3 -3
- package/src/input-processors.ts +65 -25
- package/src/pan.ts +4 -3
- package/src/renderer.ts +1 -1
- package/src/runtime-modules.d.ts +68 -1
- package/src/swipe.ts +6 -6
- package/src/textures.ts +333 -0
- package/src/transform.ts +16 -7
- package/src/types.d.ts +43 -1
- package/src/velocity.ts +113 -50
- package/src/window.ts +41 -3
package/src/input-processors.ts
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
// the plain press on another action, and the consumer reads one bool.
|
|
7
7
|
//
|
|
8
8
|
// Timing: an interaction watches its source's rate under a root of its
|
|
9
|
-
// own
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
9
|
+
// own, so it works headless and needs no frame loop; the root is disposed
|
|
10
|
+
// with the creating scope (a component body) and lives for the session
|
|
11
|
+
// without one. `hold` keeps time with setTimeout. `tap` and `doubleTap`
|
|
12
|
+
// measure between the source's edges with the source's own time
|
|
13
|
+
// (`changedAt`, the timeStamp of the input event behind each edge): they
|
|
14
|
+
// see an edge at the flush, after the event is gone, so they read no
|
|
15
|
+
// clock of their own, and a source without `changedAt` cannot sit under
|
|
16
|
+
// them. They are edges, not levels: they read pressed for one task, long
|
|
17
|
+
// enough for a map's onPress to fire, then release on their own.
|
|
14
18
|
//
|
|
15
19
|
// Every processor composes the source's id (`invert(gamepad:leftStick)`,
|
|
16
20
|
// `hold(400,keyboard:key:Space)`), so a saved binding restores through
|
|
@@ -49,6 +53,13 @@ let checkButtonSource = (what: string, source: InputSource): void => {
|
|
|
49
53
|
if (source.kind !== "button") throw new Error(`${what}: "${source.label}" is a ${source.kind}, not a button`)
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
let checkTimedSource = (what: string, source: InputSource): void => {
|
|
57
|
+
checkButtonSource(what, source)
|
|
58
|
+
if (typeof source.changedAt !== "function") {
|
|
59
|
+
throw new Error(`${what}: "${source.label}" does not say when it changes (no changedAt), so its presses cannot be timed`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
let checkMs = (what: string, ms: number): void => {
|
|
53
64
|
if (!Number.isFinite(ms) || ms < 0) throw new Error(`${what}: ms must be a non-negative number, got ${String(ms)}`)
|
|
54
65
|
}
|
|
@@ -98,11 +109,13 @@ export function scale<K extends "axis" | "vec2">(source: InputSource<K>, factor:
|
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
// A button source derived from one button source's press and release
|
|
101
|
-
// edges: `edge(down,
|
|
102
|
-
// state and `later` for a timer that the disposal clears.
|
|
112
|
+
// edges: `edge(down, at)` runs on each, with `set` for the derived
|
|
113
|
+
// state and `later` for a timer that the disposal clears. `at` is the
|
|
114
|
+
// source's time for the edge (its changedAt), null when it has none: a
|
|
115
|
+
// source that carries no time, or a value that was dropped (a blur).
|
|
103
116
|
type EdgeHooks = { set: (pressed: boolean) => void; later: (ms: number, run: () => void) => void; clearLater: () => void }
|
|
104
117
|
|
|
105
|
-
function derived(source: InputSource<"button">, label: string, id: string, edge: (down: boolean,
|
|
118
|
+
function derived(source: InputSource<"button">, label: string, id: string, edge: (down: boolean, at: number | null, hooks: EdgeHooks) => void): InputSource<"button"> {
|
|
106
119
|
// ownedWrite: the edges land inside an effect and timers.
|
|
107
120
|
let [pressed, setPressed] = createSignal(false, { ownedWrite: true })
|
|
108
121
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
@@ -124,7 +137,7 @@ function derived(source: InputSource<"button">, label: string, id: string, edge:
|
|
|
124
137
|
createEffect(
|
|
125
138
|
() => source.rate!(),
|
|
126
139
|
(down, prev) => {
|
|
127
|
-
if (down !== prev) edge(down,
|
|
140
|
+
if (down !== prev) edge(down, source.changedAt?.() ?? null, hooks)
|
|
128
141
|
},
|
|
129
142
|
{ defer: true },
|
|
130
143
|
)
|
|
@@ -151,7 +164,7 @@ let pulse = (hooks: EdgeHooks): void => {
|
|
|
151
164
|
export function hold(source: InputSource<"button">, ms = HOLD_MS): InputSource<"button"> {
|
|
152
165
|
checkButtonSource("hold", source)
|
|
153
166
|
checkMs("hold", ms)
|
|
154
|
-
return derived(source, `${source.label} (hold ${ms} ms)`, `hold(${ms},${source.id})`, (down,
|
|
167
|
+
return derived(source, `${source.label} (hold ${ms} ms)`, `hold(${ms},${source.id})`, (down, _at, hooks) => {
|
|
155
168
|
if (down) hooks.later(ms, () => hooks.set(true))
|
|
156
169
|
else {
|
|
157
170
|
hooks.clearLater()
|
|
@@ -161,40 +174,55 @@ export function hold(source: InputSource<"button">, ms = HOLD_MS): InputSource<"
|
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
/** A press released within `ms` (default 200): pressed for one task on the
|
|
164
|
-
* release, so onPress fires once per tap and a long press is not one.
|
|
177
|
+
* release, so onPress fires once per tap and a long press is not one. A
|
|
178
|
+
* press the player did not let go (a blur dropped it) is no tap. The
|
|
179
|
+
* source has to say when it changes (`changedAt`). */
|
|
165
180
|
export function tap(source: InputSource<"button">, ms = TAP_MS): InputSource<"button"> {
|
|
166
|
-
|
|
181
|
+
checkTimedSource("tap", source)
|
|
167
182
|
checkMs("tap", ms)
|
|
168
|
-
let downAt =
|
|
169
|
-
return derived(source, `${source.label} (tap)`, `tap(${ms},${source.id})`, (down,
|
|
170
|
-
if (down) downAt =
|
|
171
|
-
else
|
|
183
|
+
let downAt: number | null = null
|
|
184
|
+
return derived(source, `${source.label} (tap)`, `tap(${ms},${source.id})`, (down, at, hooks) => {
|
|
185
|
+
if (down) downAt = at
|
|
186
|
+
else {
|
|
187
|
+
if (at !== null && downAt !== null && at - downAt <= ms) pulse(hooks)
|
|
188
|
+
downAt = null
|
|
189
|
+
}
|
|
172
190
|
})
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
/** Two taps (each within `tapMs`, default 200) at most `gapMs` (default
|
|
176
|
-
* 300) apart: pressed for one task on the second release.
|
|
194
|
+
* 300) apart: pressed for one task on the second release. A press the
|
|
195
|
+
* player did not let go (a blur dropped it) is no tap and ends the pair.
|
|
196
|
+
* The source has to say when it changes (`changedAt`). */
|
|
177
197
|
export function doubleTap(source: InputSource<"button">, gapMs = DOUBLE_TAP_GAP_MS, tapMs = TAP_MS): InputSource<"button"> {
|
|
178
|
-
|
|
198
|
+
checkTimedSource("doubleTap", source)
|
|
179
199
|
checkMs("doubleTap", gapMs)
|
|
180
200
|
checkMs("doubleTap", tapMs)
|
|
181
|
-
let downAt =
|
|
201
|
+
let downAt: number | null = null
|
|
182
202
|
let lastTap = -Infinity
|
|
183
|
-
return derived(source, `${source.label} (double tap)`, `doubleTap(${gapMs},${tapMs},${source.id})`, (down,
|
|
203
|
+
return derived(source, `${source.label} (double tap)`, `doubleTap(${gapMs},${tapMs},${source.id})`, (down, at, hooks) => {
|
|
184
204
|
if (down) {
|
|
185
|
-
downAt =
|
|
205
|
+
downAt = at
|
|
186
206
|
return
|
|
187
207
|
}
|
|
188
|
-
|
|
189
|
-
|
|
208
|
+
let pressedAt = downAt
|
|
209
|
+
downAt = null
|
|
210
|
+
if (at === null || pressedAt === null) {
|
|
211
|
+
lastTap = -Infinity
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
if (at - pressedAt > tapMs) return
|
|
215
|
+
if (at - lastTap <= gapMs) {
|
|
190
216
|
lastTap = -Infinity
|
|
191
217
|
pulse(hooks)
|
|
192
|
-
} else lastTap =
|
|
218
|
+
} else lastTap = at
|
|
193
219
|
})
|
|
194
220
|
}
|
|
195
221
|
|
|
196
222
|
/** Pressed while every source is: Shift+click on one action, a two-button
|
|
197
|
-
* combo on a pad. Key events reach each keyboard source among them.
|
|
223
|
+
* combo on a pad. Key events reach each keyboard source among them. It
|
|
224
|
+
* changes when the last of its sources to change did (`changedAt`, when
|
|
225
|
+
* each of them carries one). */
|
|
198
226
|
export function chord(...sources: InputSource<"button">[]): InputSource<"button"> {
|
|
199
227
|
if (sources.length < 2) throw new Error("chord: needs at least two button sources")
|
|
200
228
|
for (let s of sources) checkButtonSource("chord", s)
|
|
@@ -204,6 +232,18 @@ export function chord(...sources: InputSource<"button">[]): InputSource<"button"
|
|
|
204
232
|
id: `chord(${sources.map(s => s.id).join(",")})`,
|
|
205
233
|
device: sources[0]!.device,
|
|
206
234
|
rate: () => sources.every(s => s.rate?.() === true),
|
|
235
|
+
changedAt: sources.every(s => s.changedAt)
|
|
236
|
+
? () => {
|
|
237
|
+
let latest: number | null = null
|
|
238
|
+
for (let s of sources) {
|
|
239
|
+
let at = s.changedAt!()
|
|
240
|
+
// One source dropped (a blur): so was the chord.
|
|
241
|
+
if (at === null) return null
|
|
242
|
+
if (latest === null || at > latest) latest = at
|
|
243
|
+
}
|
|
244
|
+
return latest
|
|
245
|
+
}
|
|
246
|
+
: undefined,
|
|
207
247
|
key: (event, down) => {
|
|
208
248
|
for (let s of sources) s.key?.(event, down)
|
|
209
249
|
},
|
package/src/pan.ts
CHANGED
|
@@ -108,7 +108,7 @@ export function createPan(options: PanOptions) {
|
|
|
108
108
|
armed = null
|
|
109
109
|
origin = { x: e.parentX, y: e.parentY }
|
|
110
110
|
tracker.reset()
|
|
111
|
-
tracker.push(e.parentX, e.parentY)
|
|
111
|
+
if (!e.predicted) tracker.push(e.parentX, e.parentY, e.timeStamp)
|
|
112
112
|
options.onPanStart?.()
|
|
113
113
|
} else {
|
|
114
114
|
// The arena is resolved against us; the drag belongs elsewhere.
|
|
@@ -117,7 +117,8 @@ export function createPan(options: PanOptions) {
|
|
|
117
117
|
return
|
|
118
118
|
}
|
|
119
119
|
if (active === e.pointerId && origin) {
|
|
120
|
-
|
|
120
|
+
// The pan follows a predicted position; its velocity does not.
|
|
121
|
+
if (!e.predicted) tracker.push(e.parentX, e.parentY, e.timeStamp)
|
|
121
122
|
options.onPanMove?.(e.parentX - origin.x, e.parentY - origin.y)
|
|
122
123
|
origin = { x: e.parentX, y: e.parentY }
|
|
123
124
|
}
|
|
@@ -126,7 +127,7 @@ export function createPan(options: PanOptions) {
|
|
|
126
127
|
if (active === e.pointerId) {
|
|
127
128
|
// The up is not a sample: it sits where the last move left the
|
|
128
129
|
// pointer, and its age is what tells a rest from a flick.
|
|
129
|
-
let velocity = flingVelocity(tracker.velocity())
|
|
130
|
+
let velocity = flingVelocity(tracker.velocity(e.timeStamp))
|
|
130
131
|
reset()
|
|
131
132
|
options.onPanEnd?.(velocity)
|
|
132
133
|
} else if (armed === e.pointerId) {
|
package/src/renderer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
-
// and
|
|
3
|
+
// and leaves it out of its public types.
|
|
4
4
|
import { createErrorBoundary, createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
|
|
5
5
|
import { createRenderer } from "@solidjs/universal"
|
|
6
6
|
import type { Element } from "solid-js"
|
package/src/runtime-modules.d.ts
CHANGED
|
@@ -61,7 +61,8 @@ declare module "*.srtm" {
|
|
|
61
61
|
// stream ("pointerMove"/"pointerDown"/... consumed by window.ts),
|
|
62
62
|
// "pointerFrame" (the move-batch terminator: fires after all of a frame's
|
|
63
63
|
// pointer moves have dispatched, every pointer the same age - multi-pointer
|
|
64
|
-
// recognizers measure there), and "render" (the
|
|
64
|
+
// recognizers measure there; its `timeStamp` is theirs), and "render" (the
|
|
65
|
+
// per-frame signal).
|
|
65
66
|
declare module "srt:events" {
|
|
66
67
|
export function on(event: string, callback: (data: any) => void): () => void
|
|
67
68
|
export function once(event: string, callback: (data: any) => void): () => void
|
|
@@ -235,3 +236,69 @@ declare module "srt:apps" {
|
|
|
235
236
|
declare module "srt:render" {
|
|
236
237
|
export function renderFrame(): void
|
|
237
238
|
}
|
|
239
|
+
|
|
240
|
+
// The engine verbs of an app test (lattice, dev client only), which
|
|
241
|
+
// @solidrt/test builds its surface on; tests import that, not this.
|
|
242
|
+
// The verbs work only in an engine a test host built (`srt test`) and throw
|
|
243
|
+
// anywhere else. App time there is frame / frameRate and nothing else: no
|
|
244
|
+
// frame runs unless one is asked for.
|
|
245
|
+
declare module "srt:test" {
|
|
246
|
+
/**
|
|
247
|
+
* Runs one frame: timers due by its time fire, then frame callbacks and
|
|
248
|
+
* the flush, then the draw if anything demanded one. Fulfills once the
|
|
249
|
+
* frame has run. One frame at a time: throws while the previous one is
|
|
250
|
+
* still pending.
|
|
251
|
+
*/
|
|
252
|
+
export function frame(): Promise<void>
|
|
253
|
+
/** The frames per second this engine steps at (60 unless set). */
|
|
254
|
+
export function frameRate(): number
|
|
255
|
+
/** Another frame rate, a positive integer. Throws once a frame has run. */
|
|
256
|
+
export function setFrameRate(fps: number): void
|
|
257
|
+
/** App time as of the last frame, in milliseconds; 0 before the first. */
|
|
258
|
+
export function time(): number
|
|
259
|
+
/**
|
|
260
|
+
* Fulfills once the window's size has reached the engine, at once when
|
|
261
|
+
* it already has. No frame runs for it and no time passes: a mounted
|
|
262
|
+
* window builds its first frame on that size.
|
|
263
|
+
*/
|
|
264
|
+
export function windowReady(): Promise<void>
|
|
265
|
+
/**
|
|
266
|
+
* Expands `events` (JSON text, the control API's `/input` event shape)
|
|
267
|
+
* into the steps that are sent and returns what passes before each:
|
|
268
|
+
* `[ms, frames]` per step, at least that much time and that many frames.
|
|
269
|
+
* Throws on an invalid event, before anything is sent. The steps are then
|
|
270
|
+
* sent with `inputStep`, in order.
|
|
271
|
+
*/
|
|
272
|
+
export function inputPlan(events: string): [ms: number, frames: number][]
|
|
273
|
+
/**
|
|
274
|
+
* Sends step `index` of the current plan into the input pipeline: a
|
|
275
|
+
* down, an up, a key or a wheel is dispatched on arrival, ahead of the
|
|
276
|
+
* next frame; a move with that frame.
|
|
277
|
+
*/
|
|
278
|
+
export function inputStep(index: number): void
|
|
279
|
+
/**
|
|
280
|
+
* Runs frames until the app is at rest: nothing in flight, no timer due,
|
|
281
|
+
* no frame demanded. Work in flight is waited for with no app time
|
|
282
|
+
* passing. Rejects once `maxMs` of app time have passed without rest,
|
|
283
|
+
* with what is left in the message.
|
|
284
|
+
*/
|
|
285
|
+
export function settle(maxMs: number): Promise<void>
|
|
286
|
+
/**
|
|
287
|
+
* Delivers a link the way an OS-routed one arrives (the raw string, on
|
|
288
|
+
* the `link` event, ahead of the next frame). Returns whether anything
|
|
289
|
+
* listens for links.
|
|
290
|
+
*/
|
|
291
|
+
export function link(link: string): boolean
|
|
292
|
+
/**
|
|
293
|
+
* Calls a debug command the app registered with `registerDebug`. `args`
|
|
294
|
+
* and the result are JSON text (null for no args). Throws on an unknown
|
|
295
|
+
* name, a throwing command and a result JSON cannot carry.
|
|
296
|
+
*/
|
|
297
|
+
export function debug(name: string, args: string | null): string
|
|
298
|
+
/**
|
|
299
|
+
* The pixels a node's subtree paints: RGBA8, premultiplied, rows top to
|
|
300
|
+
* bottom, at the display scale. Drawn at once from the tree as it is; no
|
|
301
|
+
* frame runs and no app time passes.
|
|
302
|
+
*/
|
|
303
|
+
export function capture(node: number): { width: number; height: number; data: Uint8Array }
|
|
304
|
+
}
|
package/src/swipe.ts
CHANGED
|
@@ -80,8 +80,8 @@ export function createSwipe(options: SwipeOptions) {
|
|
|
80
80
|
// The down's window position and the finger's window-px velocity, over
|
|
81
81
|
// every event the wrapped pan sees.
|
|
82
82
|
let down: { id: number; x: number; y: number } | null = null
|
|
83
|
-
// The lift's window position, set just before the pan's up runs.
|
|
84
|
-
let lift: { x: number; y: number } | null = null
|
|
83
|
+
// The lift's window position and time, set just before the pan's up runs.
|
|
84
|
+
let lift: { x: number; y: number; at: number } | null = null
|
|
85
85
|
let finger = createVelocityTracker()
|
|
86
86
|
let pan = createPan({
|
|
87
87
|
get axis() {
|
|
@@ -92,7 +92,7 @@ export function createSwipe(options: SwipeOptions) {
|
|
|
92
92
|
onPanEnd: velocity => {
|
|
93
93
|
let direction: SwipeDirection | null = null
|
|
94
94
|
if (down && lift) {
|
|
95
|
-
direction = classifySwipe(finger.velocity(), { dx: lift.x - down.x, dy: lift.y - down.y }, directions())
|
|
95
|
+
direction = classifySwipe(finger.velocity(lift.at), { dx: lift.x - down.x, dy: lift.y - down.y }, directions())
|
|
96
96
|
}
|
|
97
97
|
if (direction) options.onSwipe?.(direction, velocity)
|
|
98
98
|
options.onSwipeEnd?.(velocity)
|
|
@@ -104,16 +104,16 @@ export function createSwipe(options: SwipeOptions) {
|
|
|
104
104
|
if (down === null && (e.button == null || e.button === 0)) {
|
|
105
105
|
down = { id: e.pointerId, x: e.clientX, y: e.clientY }
|
|
106
106
|
finger.reset()
|
|
107
|
-
finger.push(e.clientX, e.clientY)
|
|
107
|
+
finger.push(e.clientX, e.clientY, e.timeStamp)
|
|
108
108
|
}
|
|
109
109
|
pan.handlers.onPointerDown(e)
|
|
110
110
|
},
|
|
111
111
|
onPointerMove: (e: PointerEvent) => {
|
|
112
|
-
if (down && down.id === e.pointerId) finger.push(e.clientX, e.clientY)
|
|
112
|
+
if (down && down.id === e.pointerId && !e.predicted) finger.push(e.clientX, e.clientY, e.timeStamp)
|
|
113
113
|
pan.handlers.onPointerMove(e)
|
|
114
114
|
},
|
|
115
115
|
onPointerUp: (e: PointerEvent) => {
|
|
116
|
-
if (down && down.id === e.pointerId) lift = { x: e.clientX, y: e.clientY }
|
|
116
|
+
if (down && down.id === e.pointerId) lift = { x: e.clientX, y: e.clientY, at: e.timeStamp }
|
|
117
117
|
pan.handlers.onPointerUp(e)
|
|
118
118
|
if (down && down.id === e.pointerId) {
|
|
119
119
|
down = null
|
package/src/textures.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// @solidrt/core/textures - what a texture bake and a loader share, without
|
|
2
|
+
// the runtime: this entry imports nothing the runtime provides, so a bake
|
|
3
|
+
// script under bun and an extension's pure modules use it as it is
|
|
4
|
+
// (tests/textures.test.ts imports it on the bare flux binary and fails the
|
|
5
|
+
// moment a gui or srt: import creeps in). Any extension that bakes or loads textures
|
|
6
|
+
// builds on this, the first-party 3d one included.
|
|
7
|
+
//
|
|
8
|
+
// isKtx2 tells a compressed texture file from a PNG or JPEG.
|
|
9
|
+
//
|
|
10
|
+
// textureSettings reads how an app's textures are compressed at bake
|
|
11
|
+
// time: the `textures` group of the `solidrt` key in its package.json.
|
|
12
|
+
//
|
|
13
|
+
// "solidrt": {
|
|
14
|
+
// "textures": {
|
|
15
|
+
// "compress": true,
|
|
16
|
+
// "data": { "codec": "uastc", "quality": 0.9 },
|
|
17
|
+
// "files": [
|
|
18
|
+
// { "match": "assets/sponza/textures/lion_*.png", "quality": 0.95 },
|
|
19
|
+
// { "match": ["assets/ui/**", "assets/hero.glb#lut"], "compress": false }
|
|
20
|
+
// ]
|
|
21
|
+
// }
|
|
22
|
+
// }
|
|
23
|
+
//
|
|
24
|
+
// Three levels, the narrower one winning: the app (`compress`), a kind of
|
|
25
|
+
// texture (`compress`, `codec`, `quality`), and the entries of `files`,
|
|
26
|
+
// each naming files by glob pattern and setting the same three for them.
|
|
27
|
+
// Whatever is left out keeps what the level above says, and a null is a
|
|
28
|
+
// key left out. Anything unknown throws naming the key.
|
|
29
|
+
//
|
|
30
|
+
// A pattern is matched against a file's path from the project root,
|
|
31
|
+
// written with "/". A texture that is a file of its own is that file; one
|
|
32
|
+
// embedded in another file (an image inside a .glb) is that file, and
|
|
33
|
+
// "#name" after the pattern picks it by its name there. The pattern
|
|
34
|
+
// language is matchesGlob's (flux:path), which the caller passes in, as
|
|
35
|
+
// it passes the scan (glob, flux:fs): this entry stays without a runtime.
|
|
36
|
+
|
|
37
|
+
// The first twelve bytes of every KTX2 file.
|
|
38
|
+
const KTX2_MAGIC = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
39
|
+
|
|
40
|
+
/** Whether `bytes` starts as a KTX2 file, the compressed texture
|
|
41
|
+
* `transcodeTexture` takes: what tells one from a PNG or JPEG. */
|
|
42
|
+
export function isKtx2(bytes: Uint8Array): boolean {
|
|
43
|
+
return bytes.length >= KTX2_MAGIC.length && KTX2_MAGIC.every((b, i) => bytes[i] === b)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The codecs a texture is encoded with: `encodeTexture`'s (flux:image),
|
|
47
|
+
* named here so that this entry needs no runtime type. "etc1s" is the
|
|
48
|
+
* small one, "uastc" the accurate one. */
|
|
49
|
+
export type TextureCodec = "etc1s" | "uastc"
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the texels of a texture ARE, which decides how it may be
|
|
53
|
+
* compressed. Not what draws it: an extension maps its own uses (a
|
|
54
|
+
* material's slots, a sprite sheet, a terrain's layers) onto these.
|
|
55
|
+
*
|
|
56
|
+
* - `color`: color as the eye sees it, sRGB-encoded. A photo, a painted
|
|
57
|
+
* surface, a sprite, a tile set.
|
|
58
|
+
* - `normal`: directions, unit vectors packed into the channels. An error
|
|
59
|
+
* tilts the lighting computed from them.
|
|
60
|
+
* - `data`: any other linear values that weigh a computation and tolerate
|
|
61
|
+
* a small error: a mask, a roughness, a height for parallax.
|
|
62
|
+
*
|
|
63
|
+
* Values that must come back EXACT are none of these and are not block
|
|
64
|
+
* compressed at all, at any setting: a lookup table, a distance field, an
|
|
65
|
+
* index map. The same goes for sharp art and text. An entry of `files`
|
|
66
|
+
* with `compress: false` keeps such a texture as its file.
|
|
67
|
+
*/
|
|
68
|
+
export type TextureKind = "color" | "normal" | "data"
|
|
69
|
+
|
|
70
|
+
export type TextureSetting = {
|
|
71
|
+
/** Whether the texture is compressed at all. False keeps its file as it
|
|
72
|
+
* is, and `codec` and `quality` say nothing then. */
|
|
73
|
+
compress: boolean
|
|
74
|
+
codec: TextureCodec
|
|
75
|
+
/** 0..1, higher is larger and closer to the source. */
|
|
76
|
+
quality: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One entry of `files`: the files it names and what it sets for them. */
|
|
80
|
+
export type TextureFileRule = {
|
|
81
|
+
/** The patterns, any of which names a file of this entry. */
|
|
82
|
+
match: string[]
|
|
83
|
+
compress?: boolean
|
|
84
|
+
codec?: TextureCodec
|
|
85
|
+
quality?: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type TextureSettings = { readonly [kind in TextureKind]: Readonly<TextureSetting> } & {
|
|
89
|
+
/** The entries in the order written; where several name one file, a
|
|
90
|
+
* later entry's fields win over an earlier one's. */
|
|
91
|
+
readonly files: readonly Readonly<TextureFileRule>[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What a texture is to the settings: the file holding it, as its path
|
|
95
|
+
* from the project root, and for a texture embedded in that file its name
|
|
96
|
+
* there. */
|
|
97
|
+
export type TextureSource = { file: string; name?: string }
|
|
98
|
+
|
|
99
|
+
/** Whether a path matches a glob pattern: `matchesGlob` of flux:path. */
|
|
100
|
+
export type GlobMatcher = (path: string, pattern: string) => boolean
|
|
101
|
+
|
|
102
|
+
/** The files a glob pattern matches, from the project root: `glob` of
|
|
103
|
+
* flux:fs. */
|
|
104
|
+
export type GlobScan = (pattern: string) => Promise<string[]>
|
|
105
|
+
|
|
106
|
+
const KINDS: TextureKind[] = ["color", "normal", "data"]
|
|
107
|
+
const CODECS: TextureCodec[] = ["etc1s", "uastc"]
|
|
108
|
+
|
|
109
|
+
// What parts a pattern that picks an embedded texture: the file before it,
|
|
110
|
+
// the name after it.
|
|
111
|
+
const NAME_MARK = "#"
|
|
112
|
+
|
|
113
|
+
// Textures are compressed unless the app says otherwise: a quarter of the
|
|
114
|
+
// GPU memory and a faster load are what an app that says nothing wants.
|
|
115
|
+
const DEFAULT_COMPRESS = true
|
|
116
|
+
|
|
117
|
+
// The defaults, by what each kind tolerates. Color takes the small codec
|
|
118
|
+
// (ETC1S moves little across its scale, so it sits high). Directions show
|
|
119
|
+
// their errors as blotches in the lighting, and data is often several
|
|
120
|
+
// unrelated values packed into the channels of one image, which the small
|
|
121
|
+
// codec handles badly: both take the accurate one, at the quality below
|
|
122
|
+
// which it gives up detail for almost no bytes. This is what the Khronos
|
|
123
|
+
// KTX guide and glTF-Transform recommend. The accurate codec is the larger
|
|
124
|
+
// download; an app that prefers the smaller one says so in its settings.
|
|
125
|
+
const SMALL_CODEC_QUALITY = 0.75
|
|
126
|
+
const ACCURATE_CODEC_QUALITY = 0.9
|
|
127
|
+
|
|
128
|
+
function frozen(settings: { [kind in TextureKind]: TextureSetting }, files: TextureFileRule[]): TextureSettings {
|
|
129
|
+
for (let kind of KINDS) Object.freeze(settings[kind])
|
|
130
|
+
for (let rule of files) {
|
|
131
|
+
Object.freeze(rule.match)
|
|
132
|
+
Object.freeze(rule)
|
|
133
|
+
}
|
|
134
|
+
return Object.freeze({ ...settings, files: Object.freeze(files) })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The settings of an app that declares none. Frozen, like every
|
|
138
|
+
* settings object this entry hands out. */
|
|
139
|
+
export const DEFAULT_TEXTURE_SETTINGS: TextureSettings = frozen(
|
|
140
|
+
{
|
|
141
|
+
color: { compress: DEFAULT_COMPRESS, codec: "etc1s", quality: SMALL_CODEC_QUALITY },
|
|
142
|
+
normal: { compress: DEFAULT_COMPRESS, codec: "uastc", quality: ACCURATE_CODEC_QUALITY },
|
|
143
|
+
data: { compress: DEFAULT_COMPRESS, codec: "uastc", quality: ACCURATE_CODEC_QUALITY },
|
|
144
|
+
},
|
|
145
|
+
[],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The fields a kind and an entry share, read into `into`. `key` names the
|
|
153
|
+
// group in a message; `others` are the group's further fields, which the
|
|
154
|
+
// caller reads.
|
|
155
|
+
function readFields(group: Record<string, unknown>, key: string, into: Partial<TextureSetting>, others: string[]) {
|
|
156
|
+
for (let [field, given] of Object.entries(group)) {
|
|
157
|
+
if (given === null || others.includes(field)) continue
|
|
158
|
+
let name = `${key}.${field}`
|
|
159
|
+
if (field === "compress") {
|
|
160
|
+
if (typeof given !== "boolean") throw new Error(`${name}: ${JSON.stringify(given)} is not true or false`)
|
|
161
|
+
into.compress = given
|
|
162
|
+
} else if (field === "codec") {
|
|
163
|
+
if (!CODECS.includes(given as TextureCodec)) throw new Error(`${name}: unknown codec ${JSON.stringify(given)} (expected "etc1s" or "uastc")`)
|
|
164
|
+
into.codec = given as TextureCodec
|
|
165
|
+
} else if (field === "quality") {
|
|
166
|
+
if (typeof given !== "number" || !(given >= 0 && given <= 1)) throw new Error(`${name}: ${JSON.stringify(given)} is not a number in 0..1`)
|
|
167
|
+
into.quality = given
|
|
168
|
+
} else {
|
|
169
|
+
throw new Error(`${name}: unknown field (expected ${[...others, "compress", "codec", "quality"].join(", ")})`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A pattern as its file part and, after the mark, its name part. A mark
|
|
175
|
+
// inside a set ("[#]") is a character of the file's name.
|
|
176
|
+
function splitPattern(pattern: string): { file: string; name?: string } {
|
|
177
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
178
|
+
let c = pattern[i]
|
|
179
|
+
if (c === "[") {
|
|
180
|
+
// A "]" right after the opening, or after the negation, is a member.
|
|
181
|
+
let first = pattern[i + 1] === "!" ? i + 2 : i + 1
|
|
182
|
+
let close = pattern.indexOf("]", first + 1)
|
|
183
|
+
if (close < 0) break
|
|
184
|
+
i = close
|
|
185
|
+
} else if (c === NAME_MARK) {
|
|
186
|
+
return { file: pattern.slice(0, i), name: pattern.slice(i + 1) }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { file: pattern }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readRule(given: unknown, key: string): TextureFileRule {
|
|
193
|
+
if (!isRecord(given)) throw new Error(`${key} must be an object with match and what it sets`)
|
|
194
|
+
let match = typeof given.match === "string" ? [given.match] : given.match
|
|
195
|
+
if (!Array.isArray(match) || match.length === 0 || !match.every((pattern) => typeof pattern === "string" && pattern !== "")) {
|
|
196
|
+
throw new Error(`${key}.match must be a pattern or a list of patterns`)
|
|
197
|
+
}
|
|
198
|
+
for (let pattern of match as string[]) {
|
|
199
|
+
let { file, name } = splitPattern(pattern)
|
|
200
|
+
if (file === "" || name === "") {
|
|
201
|
+
throw new Error(`${key}.match: ${JSON.stringify(pattern)} must name a file before "${NAME_MARK}" and a texture after it`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
let rule: TextureFileRule = { match: match as string[] }
|
|
205
|
+
readFields(given, key, rule, ["match"])
|
|
206
|
+
if (rule.compress === undefined && rule.codec === undefined && rule.quality === undefined) {
|
|
207
|
+
throw new Error(`${key} sets nothing (expected compress, codec or quality)`)
|
|
208
|
+
}
|
|
209
|
+
if (rule.compress === false && (rule.codec !== undefined || rule.quality !== undefined)) {
|
|
210
|
+
throw new Error(`${key}: compress is false, which leaves codec and quality nothing to apply to`)
|
|
211
|
+
}
|
|
212
|
+
return rule
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The texture settings of a parsed package.json: its `solidrt.textures`
|
|
217
|
+
* group over the defaults. A package.json without the group (or none at
|
|
218
|
+
* all: pass undefined) gives the defaults.
|
|
219
|
+
*
|
|
220
|
+
* `run.compress` stands in for the app's own `compress` for this one run
|
|
221
|
+
* (a bake's `--compress` and `--no-compress`); what a kind or an entry
|
|
222
|
+
* says about itself still holds.
|
|
223
|
+
*
|
|
224
|
+
* Throws on an unknown key or field, a codec that does not exist, a
|
|
225
|
+
* quality outside 0..1, an entry without a pattern or that sets nothing.
|
|
226
|
+
* The patterns themselves are read by the matcher: `unmatchedTextureFiles`
|
|
227
|
+
* runs every one of them, so a bake that starts with it meets a malformed
|
|
228
|
+
* pattern before it encodes anything.
|
|
229
|
+
*/
|
|
230
|
+
export function textureSettings(packageJson: unknown, run: { compress?: boolean } = {}): TextureSettings {
|
|
231
|
+
let solidrt = isRecord(packageJson) ? packageJson.solidrt : undefined
|
|
232
|
+
let group = isRecord(solidrt) ? (solidrt.textures ?? undefined) : undefined
|
|
233
|
+
if (group !== undefined && !isRecord(group)) {
|
|
234
|
+
throw new Error("solidrt.textures must be an object (compress, files, and the texture kinds color, normal, data)")
|
|
235
|
+
}
|
|
236
|
+
let given: Record<string, unknown> = group ?? {}
|
|
237
|
+
|
|
238
|
+
let app: Partial<TextureSetting> = {}
|
|
239
|
+
if (given.compress !== undefined && given.compress !== null) {
|
|
240
|
+
if (typeof given.compress !== "boolean") throw new Error(`solidrt.textures.compress: ${JSON.stringify(given.compress)} is not true or false`)
|
|
241
|
+
app.compress = given.compress
|
|
242
|
+
}
|
|
243
|
+
let compress = run.compress ?? app.compress ?? DEFAULT_COMPRESS
|
|
244
|
+
|
|
245
|
+
let kinds = {} as { [kind in TextureKind]: TextureSetting }
|
|
246
|
+
for (let kind of KINDS) kinds[kind] = { ...DEFAULT_TEXTURE_SETTINGS[kind], compress }
|
|
247
|
+
let files: TextureFileRule[] = []
|
|
248
|
+
for (let [key, value] of Object.entries(given)) {
|
|
249
|
+
if (key === "compress" || value === null) continue
|
|
250
|
+
let name = `solidrt.textures.${key}`
|
|
251
|
+
if (key === "files") {
|
|
252
|
+
if (!Array.isArray(value)) throw new Error(`${name} must be a list of entries, each with match and what it sets`)
|
|
253
|
+
files = value.map((rule, i) => readRule(rule, `${name}[${i}]`))
|
|
254
|
+
} else if (KINDS.includes(key as TextureKind)) {
|
|
255
|
+
if (!isRecord(value)) throw new Error(`${name} must be an object with compress, codec and quality`)
|
|
256
|
+
readFields(value, name, kinds[key as TextureKind], [])
|
|
257
|
+
} else {
|
|
258
|
+
throw new Error(`${name}: unknown key (expected compress, files, or a texture kind: color, normal, data)`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return frozen(kinds, files)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Whether a pattern names the source: its file part matches the file, and
|
|
265
|
+
// its name part, when it has one, the name of a texture embedded there.
|
|
266
|
+
function patternNames(pattern: string, source: TextureSource, matches: GlobMatcher): boolean {
|
|
267
|
+
let { file, name } = splitPattern(pattern)
|
|
268
|
+
if (!matches(source.file, file)) return false
|
|
269
|
+
return name === undefined || (source.name !== undefined && matches(source.name, name))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* How one texture is compressed: what its kind says, under the fields of
|
|
274
|
+
* every entry of `files` that names it, in the order written. A pattern
|
|
275
|
+
* without a name part names a file and everything embedded in it; one
|
|
276
|
+
* with a name part names the embedded textures of that name, never a
|
|
277
|
+
* texture that is a file of its own.
|
|
278
|
+
*
|
|
279
|
+
* @param matches `matchesGlob` of flux:path.
|
|
280
|
+
*/
|
|
281
|
+
export function textureSettingFor(settings: TextureSettings, kind: TextureKind, source: TextureSource, matches: GlobMatcher): TextureSetting {
|
|
282
|
+
let setting: TextureSetting = { ...settings[kind] }
|
|
283
|
+
for (let rule of settings.files) {
|
|
284
|
+
if (!rule.match.some((pattern) => patternNames(pattern, source, matches))) continue
|
|
285
|
+
if (rule.compress !== undefined) setting.compress = rule.compress
|
|
286
|
+
if (rule.codec !== undefined) setting.codec = rule.codec
|
|
287
|
+
if (rule.quality !== undefined) setting.quality = rule.quality
|
|
288
|
+
}
|
|
289
|
+
return setting
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The patterns of `files`, as written, whose file matches nothing in the
|
|
294
|
+
* project: a misspelled pattern, which would otherwise do nothing and say
|
|
295
|
+
* nothing. The settings are the app's and a bake sees one file of it, so
|
|
296
|
+
* the question goes to the project's files, not to what is being baked.
|
|
297
|
+
* A bake calls this first and fails on what it returns.
|
|
298
|
+
*
|
|
299
|
+
* @param scan `(pattern) => glob(pattern, { cwd })` of flux:fs, with the
|
|
300
|
+
* project root as cwd. It throws on a malformed pattern.
|
|
301
|
+
*/
|
|
302
|
+
export async function unmatchedTextureFiles(settings: TextureSettings, scan: GlobScan): Promise<string[]> {
|
|
303
|
+
let unmatched: string[] = []
|
|
304
|
+
for (let rule of settings.files) {
|
|
305
|
+
for (let pattern of rule.match) {
|
|
306
|
+
let found = await scan(splitPattern(pattern).file)
|
|
307
|
+
if (found.length === 0) unmatched.push(pattern)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return unmatched
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The patterns of `files`, as written, that pick an embedded texture of
|
|
315
|
+
* `file` by a name none of its textures has. What `unmatchedTextureFiles`
|
|
316
|
+
* cannot see, since it needs the file opened: the bake of that file calls
|
|
317
|
+
* this with the names it found and fails on what it returns.
|
|
318
|
+
*
|
|
319
|
+
* @param file The file being baked, as its path from the project root.
|
|
320
|
+
* @param names The names of the textures embedded in it.
|
|
321
|
+
* @param matches `matchesGlob` of flux:path.
|
|
322
|
+
*/
|
|
323
|
+
export function unmatchedTextureNames(settings: TextureSettings, file: string, names: string[], matches: GlobMatcher): string[] {
|
|
324
|
+
let unmatched: string[] = []
|
|
325
|
+
for (let rule of settings.files) {
|
|
326
|
+
for (let pattern of rule.match) {
|
|
327
|
+
let split = splitPattern(pattern)
|
|
328
|
+
if (split.name === undefined || !matches(file, split.file)) continue
|
|
329
|
+
if (!names.some((name) => matches(name, split.name!))) unmatched.push(pattern)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return unmatched
|
|
333
|
+
}
|