@solidrt/3d 0.0.63 → 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/src/model.ts CHANGED
@@ -13,15 +13,17 @@
13
13
  // them and detaches the group. loadGltf / loadModel are the
14
14
  // read-then-create conveniences over flux:fs; parseGltf / decodeModel +
15
15
  // createModel are the primitives under them, for bytes obtained any other
16
- // way (a binary import, a fetch).
16
+ // way (a binary import, a fetch). Compressed images (KTX2, what `srt tool
17
+ // 3d/model` bakes) are turned into this device's block format by
18
+ // transcodeModelImages, the one asynchronous step between the two.
17
19
 
18
20
  import { file } from "flux:fs"
19
21
  import * as spatial from "flux:spatial"
20
- import { decodeImage, getOwner, isDisposed, onCleanup, runWithOwner } from "@solidrt/core"
22
+ import { decodeImage, getOwner, isDisposed, isKtx2, onCleanup, runWithOwner, transcodeTexture } from "@solidrt/core"
21
23
  import type { Owner } from "@solidrt/core"
22
24
  import { createMutableTexture, createTexture, destroyTexture } from "@solidrt/core/gpu"
23
25
  import type { TextureId } from "@solidrt/core/gpu"
24
- import { gltfExternalUris, parseGltf } from "./gltf.ts"
26
+ import { gltfExternalUris, modelImageUses, parseGltf } from "./gltf.ts"
25
27
  import type { ModelClip, ModelData, ModelExtras, ModelMaterial } from "./gltf.ts"
26
28
  import { decodeModel } from "./model-file.ts"
27
29
  import { disposeGeometry } from "./geometry-gpu.ts"
@@ -144,6 +146,20 @@ export type ModelSkinNodes = {
144
146
  jointBounds: Float32Array
145
147
  }
146
148
 
149
+ /**
150
+ * Transcode the model's compressed images (KTX2) into this device's block
151
+ * format, off the JS thread (all are started at once; the runtime runs a
152
+ * few at a time), and return the data with them as `textures`; the images that are PNG or JPEG are left for
153
+ * createModel to decode. The step between a parse (or decodeModel) and
154
+ * createModel for data that may hold KTX2 images; loadModel and loadGltf
155
+ * run it. Data with no KTX2 image comes back as it is.
156
+ */
157
+ export async function transcodeModelImages(data: ModelData): Promise<ModelData> {
158
+ if (!data.images.some((image) => isKtx2(image.bytes))) return data
159
+ let textures = await Promise.all(data.images.map((image) => (isKtx2(image.bytes) ? transcodeTexture(image.bytes) : undefined)))
160
+ return { ...data, textures }
161
+ }
162
+
147
163
  /**
148
164
  * Build the scene object for parsed model data: upload its images (repeat
149
165
  * wrap, mipmapped, MODEL_ANISOTROPY; the base color and emissive images
@@ -151,24 +167,26 @@ export type ModelSkinNodes = {
151
167
  * rgba8), make a material per glTF material, the file's node hierarchy as
152
168
  * nested Groups, and a mesh per part under its node. Synchronous - the
153
169
  * data is already in memory.
170
+ *
171
+ * A compressed image uploads as transcodeModelImages left it: at the
172
+ * format and color space its file names, with the mip chain the file
173
+ * carries (none can be generated for compressed storage). Data holding a
174
+ * KTX2 image that was not transcoded throws.
154
175
  */
155
176
  export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
156
177
  let label = opts.label
157
- let colorImages = new Set<number>()
158
- for (let m of data.materials) {
159
- if (m.map !== null) colorImages.add(m.map)
160
- if (m.emissiveMap !== null) colorImages.add(m.emissiveMap)
161
- }
162
- let textures: TextureId[] = data.images.map((bytes, i) => {
163
- let image = decodeImage(bytes)
164
- return createTexture(image.data, image.width, image.height, {
165
- format: colorImages.has(i) ? "rgba8-srgb" : "rgba8",
166
- wrap: "repeat",
167
- mipmap: true,
168
- anisotropy: MODEL_ANISOTROPY,
169
- autoFree: false,
170
- label: label ? label + "-image" + i : undefined,
171
- })
178
+ let uses = modelImageUses(data)
179
+ let textures: TextureId[] = data.images.map((image, i) => {
180
+ let sampling = { wrap: "repeat" as const, anisotropy: MODEL_ANISOTROPY, autoFree: false, label: label ? label + "-" + image.name : image.name }
181
+ let ready = data.textures?.[i]
182
+ if (ready !== undefined) {
183
+ return createTexture(ready.data, ready.width, ready.height, { ...sampling, format: ready.format, mipmap: ready.mipmap })
184
+ }
185
+ if (isKtx2(image.bytes)) {
186
+ throw new Error("createModel: image '" + image.name + "' is a KTX2 compressed texture, which is transcoded before the model is created: await transcodeModelImages(data) first (loadModel and loadGltf do)")
187
+ }
188
+ let decoded = decodeImage(image.bytes)
189
+ return createTexture(decoded.data, decoded.width, decoded.height, { ...sampling, format: uses[i]!.srgb ? "rgba8-srgb" : "rgba8", mipmap: true })
172
190
  })
173
191
  let make = opts.material ?? ((m: ModelMaterial, maps: ModelMaps, skinned: boolean, vertexColors: boolean, morphed: boolean, instanced: boolean): Material => {
174
192
  // An emissive factor of zero is emission OFF (the glTF product rule:
@@ -434,7 +452,8 @@ export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model
434
452
  for (let uri of gltfExternalUris(bytes)) {
435
453
  if (!files.has(uri)) files.set(uri, await file(dir + decodeURIComponent(uri)).bytes())
436
454
  }
437
- return buildOwned(owner, opts, () => createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts))
455
+ let data = await transcodeModelImages(parseGltf(bytes, (uri) => files.get(uri)!))
456
+ return buildOwned(owner, opts, () => createModel(data, opts))
438
457
  }
439
458
 
440
459
  /** Read a baked .srtm model (`srt tool 3d/model`) and build it: no parsing,
@@ -442,5 +461,6 @@ export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model
442
461
  export async function loadModel(path: string, opts?: ModelOptions): Promise<Model> {
443
462
  let owner = getOwner()
444
463
  let bytes = await file(path).bytes()
445
- return buildOwned(owner, opts, () => createModel(decodeModel(bytes), opts))
464
+ let data = await transcodeModelImages(decodeModel(bytes))
465
+ return buildOwned(owner, opts, () => createModel(data, opts))
446
466
  }
package/src/node.ts CHANGED
@@ -163,6 +163,9 @@ export type SceneHooks = {
163
163
  _setLayers(mesh: Mesh): void
164
164
  /** A light's castShadow/shadow options changed. */
165
165
  _shadowChanged(light: CastingLight): void
166
+ /** A light's layers mask changed: re-filter its shadow views' casters
167
+ * (the uniform rewrite rides the _lightChanged that follows). */
168
+ _lightLayersChanged(light: Light): void
166
169
  _reorder(mesh: Mesh): void
167
170
  /** The node's transform changed (for the sort and light bookkeeping). */
168
171
  _moved(node: SceneNode): void
@@ -270,6 +273,11 @@ export type SceneEventBase = {
270
273
  * (a view's own pixels under a view leaf). */
271
274
  x: number
272
275
  y: number
276
+ /** When the event happened, ms: the element event's `timeStamp`. */
277
+ timeStamp: number
278
+ /** The element event's `predicted`: a move the pointer was never
279
+ * reported at. Draw with it, do not measure with it. */
280
+ predicted: boolean
273
281
  pointerId: number
274
282
  pointerType: string
275
283
  button?: number
@@ -19,7 +19,7 @@
19
19
  // dispatch.ts is the same model one dimension down.
20
20
  //
21
21
  // Pure BY DESIGN (types only, nothing with GPU imports) so
22
- // checks/dispatch-check.ts drives it headless with a fake pick.
22
+ // tests/dispatch.test.ts drives it headless with a fake pick.
23
23
 
24
24
  import type { PointerEvent as ElementPointerEvent, PointerFeed } from "@solidrt/core"
25
25
  import type { NodePointerEvent, SceneNode, ScenePointerListener } from "./node.ts"
@@ -58,6 +58,8 @@ type InternalEvent = {
58
58
  distance: number | null
59
59
  x: number
60
60
  y: number
61
+ timeStamp: number
62
+ predicted: boolean
61
63
  pointerId: number
62
64
  pointerType: string
63
65
  button?: number
@@ -95,8 +97,6 @@ export type PointerInputDeps = {
95
97
  root: Scene | ViewHandle
96
98
  /** The root's listeners, in registration order. */
97
99
  listeners: Set<ScenePointerListener>
98
- /** The clock for tap repeats (default performance.now; checks inject). */
99
- now?: () => number
100
100
  }
101
101
 
102
102
  export type PointerInput = {
@@ -108,7 +108,6 @@ export type PointerInput = {
108
108
  }
109
109
 
110
110
  export function makePointerInput(deps: PointerInputDeps): PointerInput {
111
- let now = deps.now ?? (() => performance.now())
112
111
  let presses = new Map<number, Press>()
113
112
  let hover = new Map<number, Target>()
114
113
  let lastTap: { time: number; cx: number; cy: number; target: Target | null; count: number } | null = null
@@ -170,6 +169,8 @@ export function makePointerInput(deps: PointerInputDeps): PointerInput {
170
169
  distance: hit === null ? null : hit.distance,
171
170
  x,
172
171
  y,
172
+ timeStamp: e.timeStamp,
173
+ predicted: e.predicted,
173
174
  pointerId: e.pointerId,
174
175
  pointerType: e.pointerType,
175
176
  button: e.button,
@@ -245,7 +246,7 @@ export function makePointerInput(deps: PointerInputDeps): PointerInput {
245
246
  if (press.moved || !press.alone) return
246
247
  let release = nearest(x, y)
247
248
  if (press.target === null ? release !== null : release === null || !sameHit(release, press.target)) return
248
- let time = now()
249
+ let time = e.timeStamp
249
250
  let repeat = lastTap !== null && time - lastTap.time <= TAP_INTERVAL_MS && sameTarget(lastTap.target, press.target) && Math.hypot(e.clientX - lastTap.cx, e.clientY - lastTap.cy) <= TAP_REPEAT_SLOP
250
251
  let count = repeat ? lastTap!.count + 1 : 1
251
252
  lastTap = { time, cx: e.clientX, cy: e.clientY, target: press.target, count }
@@ -80,8 +80,9 @@ export type ShadowSystemDeps<V extends ShadowView> = {
80
80
  targetSize(): { width: number; height: number }
81
81
  /** Base debug label ("scene" by default). */
82
82
  label: string
83
- /** Make one shadow view: a tile of `into` drawing `filter`'s meshes
84
- * with the depth override (the scene's makeView). */
83
+ /** Make one shadow view for `light`: a tile of `into` drawing the
84
+ * casting meshes the light's layers admit with the depth override (the
85
+ * scene's makeView derives the caster filter and the layer mask). */
85
86
  makeView(
86
87
  vopts: {
87
88
  width: number
@@ -93,7 +94,7 @@ export type ShadowSystemDeps<V extends ShadowView> = {
93
94
  clearColor: [number, number, number, number]
94
95
  label: string
95
96
  },
96
- filter: (mesh: Mesh) => boolean,
97
+ light: CastingLight,
97
98
  ): V
98
99
  disposeView(view: V): void
99
100
  /** The light set changed shape: the scene owes a writeLights. */
@@ -203,9 +204,9 @@ export function makeShadowSystem<V extends ShadowView>(deps: ShadowSystemDeps<V>
203
204
  deps.schedule()
204
205
  }
205
206
  // A caster's views: one square tile of the shadow atlas per map,
206
- // drawing the casting meshes with the depth override from that map's
207
- // frustum. The light rewrite writes the rects in the light's slots on
208
- // every receiving target.
207
+ // drawing the casting meshes the light's layers admit with the depth
208
+ // override from that map's frustum. The light rewrite writes the rects
209
+ // in the light's slots on every receiving target.
209
210
  let createShadow = (light: CastingLight, rects: ShadowRect[]) => {
210
211
  let atlas = shadowAtlas!
211
212
  let views = rects.map(rect =>
@@ -220,7 +221,7 @@ export function makeShadowSystem<V extends ShadowView>(deps: ShadowSystemDeps<V>
220
221
  clearColor: [1, 1, 1, 1],
221
222
  label: deps.label + "-shadow",
222
223
  },
223
- m => m.castShadow,
224
+ light,
224
225
  ),
225
226
  )
226
227
  shadows.set(light, { light, views, lastWorld: mat4(), dirty: true, rects })