@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/mesh.ts CHANGED
@@ -3,8 +3,10 @@
3
3
  // setter write paths that keep a live scene's draw entries in step. The
4
4
  // scene side is reached through the node's SceneHooks (node.ts).
5
5
 
6
- import { createBuffer, destroyBuffer, destroyTexture, writeBuffer } from "@solidrt/core/gpu"
6
+ import { beginBufferWrite, createBuffer, destroyBuffer, destroyTexture, endBufferWrite, transferRecords, writeBuffer } from "@solidrt/core/gpu"
7
7
  import type { BufferId, DrawId, ShaderParams, TextureBindings, TextureId, VertexAttribute, VertexBufferLayout } from "@solidrt/core/gpu"
8
+ import { checkCubeKnobs } from "./environment.ts"
9
+ import type { EnvironmentOptions } from "./environment.ts"
8
10
  import { geometryBounds, isFloatLayout, layoutKey, layoutStride, plane, vertexBytes, vertexView, VERTEX_FORMATS } from "./geometry.ts"
9
11
  import type { AttributeAccess, Geometry } from "./geometry.ts"
10
12
  import { INSTANCE_MATRIX_ATTRIBUTES } from "./glsl.ts"
@@ -32,6 +34,15 @@ export type Mesh = SceneNode & {
32
34
  * target draws the mesh when its mask intersects this. Not inherited
33
35
  * from ancestor Groups (Three's and Godot's rule). Set with setLayers. */
34
36
  layers: number
37
+ /** This mesh's environment instead of the scene's (resolved; set with
38
+ * setEnvironment): the cube its image-lit and reflective terms sample,
39
+ * `null` for NONE (a sealed interior under an outdoor sky reflects and
40
+ * receives no sky), `undefined` (the default) to follow the scene's,
41
+ * live through scene.setEnvironment. Applied where the material
42
+ * reflects at all (Material.env - `standard`, `phong` with
43
+ * `reflectivity`, a custom source composing ENVIRONMENT); on any other
44
+ * material it is a no-op, so an interior can be masked wholesale. */
45
+ environment: { cube: TextureId; intensity: number; rotation: number } | null | undefined
35
46
  /** Whether every target's frustum gates the mesh (default true, Three's
36
47
  * `frustumCulled`): outside it the entry draws nothing. Off for geometry
37
48
  * a vertex stage moves beyond its box (a fullscreen quad, a custom
@@ -130,6 +141,31 @@ export type MeshInstances = {
130
141
  * shader reads at row gl_InstanceID. Replaced on growth (the entries
131
142
  * re-point), freed by disposeInstances. */
132
143
  morph: { texture: TextureId; rows: number } | null
144
+ /** The mesh's instanceOrder resolved against its material's instance
145
+ * layouts (see InstanceOrderOptions), or null for buffer order. Fixed
146
+ * at creation; slots as the app writes them never move. */
147
+ order: ResolvedInstanceOrder | null
148
+ /** Whether the records were transferred to the engine at creation
149
+ * (RecordMeshOptions.transfer, or an indexed instanceOrder): no JS-side
150
+ * mirror exists, the population is write-once, and rewrites/growth/
151
+ * attribute access throw. Fixed at creation; always false on an
152
+ * instanced mesh. */
153
+ transfer: boolean
154
+ /** Records per drawn instance: 1, or on an indexed instanceOrder the
155
+ * ids per id-stream record (the entry's instanceCount is
156
+ * ceil(count / group), see drawCount). Fixed at creation. */
157
+ group: number
158
+ /** Data textures the population owns (an indexed population's record
159
+ * texture and the like, created by whoever built the mesh), freed by
160
+ * disposeInstances with the buffers. */
161
+ textures: TextureId[]
162
+ }
163
+
164
+ /** The draw's instance count for a population: its records over the
165
+ * records per instance, rounded up (an indexed population's last group
166
+ * pads with the engine's sentinel ids). */
167
+ export function drawCount(inst: MeshInstances): number {
168
+ return Math.ceil(inst.count / inst.group)
133
169
  }
134
170
 
135
171
  export type InstanceSlots = { slots: (InstanceNode | null)[]; free: number[] }
@@ -213,6 +249,7 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
213
249
  mesh.renderOrder = 0
214
250
  mesh.castShadow = false
215
251
  mesh.layers = 1
252
+ mesh.environment = undefined
216
253
  mesh.frustumCulled = true
217
254
  mesh.cullMargin = 0
218
255
  mesh._cullJoints = null
@@ -341,6 +378,55 @@ function makeStream(layout: VertexAttribute[], capacity: number, blank: Uint8Arr
341
378
  return stream
342
379
  }
343
380
 
381
+ // The indexed order's stream: the id buffer the core writes whole with the
382
+ // sorted record indices (ceil(capacity / group) instance records of
383
+ // `group` u32 ids each; it starts zeroed and the entry's attach seeds the
384
+ // first sort), the records handed to the core's order mirror as the key
385
+ // source. No JS mirror exists, as under transfer.
386
+ function makeIndexStream(layout: VertexAttribute[], capacity: number, group: number, records: Uint8Array, label: string | undefined): InstanceStream {
387
+ let stride = layoutStride(layout)
388
+ let stream: InstanceStream = {
389
+ layout,
390
+ stride,
391
+ buffer: createBuffer(Math.ceil(capacity / group) * stride, { autoFree: false, label }),
392
+ data: new Uint8Array(0),
393
+ blank: new Uint8Array(stride),
394
+ dirty: null,
395
+ _view: new DataView(new ArrayBuffer(0)),
396
+ _floats: false,
397
+ _components: layoutComponents(layout),
398
+ _fields: [],
399
+ }
400
+ transferRecords(stream.buffer, records)
401
+ return stream
402
+ }
403
+
404
+ // The transfer form's stream: records go to the engine at creation and no
405
+ // JS mirror exists (data stays a zero-length view; the operations that
406
+ // would read or write it throw on the transfer flag first). An ordered
407
+ // stream hands the records to the core's order mirror - the entry's
408
+ // attach publishes the first gather, so the buffer starts zeroed; an
409
+ // unordered one is its own upload, the buffer created with the records.
410
+ function makeTransferredStream(layout: VertexAttribute[], records: Uint8Array, ordered: boolean, label: string | undefined): InstanceStream {
411
+ let stride = layoutStride(layout)
412
+ let stream: InstanceStream = {
413
+ layout,
414
+ stride,
415
+ buffer: ordered
416
+ ? createBuffer(records.byteLength, { autoFree: false, label })
417
+ : createBuffer(records, { autoFree: false, label }),
418
+ data: new Uint8Array(0),
419
+ blank: new Uint8Array(stride),
420
+ dirty: null,
421
+ _view: new DataView(new ArrayBuffer(0)),
422
+ _floats: isFloatLayout(layout),
423
+ _components: layoutComponents(layout),
424
+ _fields: [],
425
+ }
426
+ if (ordered) transferRecords(stream.buffer, records)
427
+ return stream
428
+ }
429
+
344
430
  // Encode `values` (one per component, in layout order, as the shader
345
431
  // sees them) into record `i` of a stream: one typed-array set over an
346
432
  // all-float layout (the values ARE the bytes), the codecs otherwise.
@@ -380,6 +466,155 @@ function markRecords(mesh: Mesh, stream: InstanceStream, lo: number, hi: number)
380
466
  mesh._scene?._setRecords(mesh)
381
467
  }
382
468
 
469
+ /**
470
+ * Draw a population's records in key order, decided core-side - the
471
+ * engine's instance order riding the mesh's record streams
472
+ * (createInstancedMesh and createRecordMesh both take it). Record slots
473
+ * never move: the app keeps writing slot i forever, and the engine
474
+ * gathers the records into key order as they publish.
475
+ *
476
+ * `position` names an instance attribute (float32x3 or float32x4) whose
477
+ * first three floats are a position in the mesh's local space: records
478
+ * draw by their depth along the scene camera's view direction, which the
479
+ * core feeds itself whenever the camera or the mesh turns meaningfully -
480
+ * zero per-frame JS, and a parked camera costs nothing. `descending:
481
+ * true` draws farthest first, the transparent blend order. The order
482
+ * follows the scene's OWN camera: one buffer holds one order, so views
483
+ * draw the same records in that order, and a mesh masked out of the
484
+ * scene target keeps its last order. `retain: true` opts a write-once
485
+ * population (a splat cloud) into the engine's retained copy: a camera
486
+ * turn re-sorts and republishes core-side with no publish from here, and
487
+ * an order-preserving turn uploads nothing.
488
+ *
489
+ * `field` names a float32 instance attribute: records draw by that value
490
+ * as the app wrote it (an age, an explicit sort key), re-ordered when
491
+ * the records publish.
492
+ *
493
+ * An ordered mesh gives up partial record publishes: any write sends the
494
+ * live record set whole, gathered (a byte range has no stable position
495
+ * under a permutation).
496
+ *
497
+ * The INDEXED form (`records`) is for a population whose records live in
498
+ * a data texture the shader fetches by record index (a splat cloud):
499
+ * `records` is the layout of the records createRecordMesh received -
500
+ * `position` names a float32x3/x4 attribute IN THAT LAYOUT - and the
501
+ * material's one instance buffer is the ID STREAM, uint32/uint32x4
502
+ * attributes the engine fills with the sorted record indices, K ids per
503
+ * instance record so one drawn instance covers K records (the
504
+ * geometry repeats K times; `gl_VertexID` picks the id). The records go
505
+ * to the engine at creation (transfer implied, retain by nature) as the
506
+ * sort's key source; the app uploads the same bytes as its texture. A
507
+ * re-sort then uploads 4 bytes per record instead of the record, and
508
+ * the last instance's group past `count` carries 0xFFFFFFFF, which the
509
+ * shader culls before fetching.
510
+ */
511
+ export type InstanceOrderOptions =
512
+ | (({ position: string; retain?: boolean } | { field: string }) & {
513
+ /** Largest key first - back-to-front for a position key. Default false. */
514
+ descending?: boolean
515
+ })
516
+ | {
517
+ /** The position attribute of the record layout below. */
518
+ position: string
519
+ /** The layout of the handed-off records the shader fetches by
520
+ * index; the material's instance buffer is the id stream. */
521
+ records: VertexAttribute[]
522
+ /** Largest key first - back-to-front. Default false. */
523
+ descending?: boolean
524
+ }
525
+
526
+ /** The instanceOrder resolved against the material's instance layouts at
527
+ * creation: the key stream's layout key (the entry's pipeline buffer is
528
+ * found by it at attach, exactly as buffer bindings are), the key's
529
+ * float offset inside one record, and the declared knobs. */
530
+ export type ResolvedInstanceOrder = {
531
+ key: string
532
+ floats: number
533
+ position: boolean
534
+ descending: boolean
535
+ retain: boolean
536
+ /** The index materialization (InstanceOrderOptions' `records` form):
537
+ * the handed-off record's byte stride; null for gathered records. */
538
+ indices: { stride: number } | null
539
+ }
540
+
541
+ // Resolve an instanceOrder option against the material's instance
542
+ // layouts: the named attribute must exist in one of them, hold floats of
543
+ // the key's shape, and start on a 4-byte boundary of its record. The
544
+ // indexed form resolves the key in the given record layout instead, and
545
+ // the material's one instance buffer must be whole u32 ids.
546
+ function resolveInstanceOrder(order: InstanceOrderOptions | undefined, buffers: VertexBufferLayout[], site: string): ResolvedInstanceOrder | null {
547
+ if (order === undefined) return null
548
+ if ("records" in order) {
549
+ if (buffers.length !== 1) {
550
+ throw new Error(site + ": an indexed instanceOrder takes a material with one instance buffer (the id stream); this one declares " + buffers.length)
551
+ }
552
+ let ids = buffers[0]!.attributes
553
+ for (let a of ids) {
554
+ if (!a.format.startsWith("uint32")) throw new Error(site + ": the id stream's attribute '" + a.name + "' must be uint32, uint32x2, uint32x3 or uint32x4 (whole record ids), got " + a.format)
555
+ }
556
+ let stride = layoutStride(order.records)
557
+ if (stride === 0 || stride % 4 !== 0) throw new Error(site + ": the indexed record layout is " + stride + " bytes; records must be whole floats")
558
+ let at = 0
559
+ for (let a of order.records) {
560
+ if (a.name === order.position) {
561
+ if (a.format !== "float32x3" && a.format !== "float32x4") {
562
+ throw new Error(site + ": instanceOrder position attribute '" + a.name + "' must be float32x3 or float32x4, got " + a.format)
563
+ }
564
+ if (at % 4 !== 0) throw new Error(site + ": instanceOrder key attribute '" + a.name + "' starts at byte " + at + " of its record; the key must start on a 4-byte boundary")
565
+ return { key: layoutKey(ids), floats: at / 4, position: true, descending: order.descending === true, retain: true, indices: { stride } }
566
+ }
567
+ at += VERTEX_FORMATS[a.format].bytes
568
+ }
569
+ throw new Error(site + ": instanceOrder names attribute '" + order.position + "', which the record layout does not declare")
570
+ }
571
+ let position = "position" in order
572
+ let name = position ? (order as { position: string }).position : (order as { field: string }).field
573
+ if (!position && (order as { retain?: boolean }).retain !== undefined) {
574
+ throw new Error(site + ": instanceOrder retain applies to a position key; a field key re-orders when its records publish")
575
+ }
576
+ for (let b of buffers) {
577
+ let at = 0
578
+ for (let a of b.attributes) {
579
+ if (a.name === name) {
580
+ if (position && a.format !== "float32x3" && a.format !== "float32x4") {
581
+ throw new Error(site + ": instanceOrder position attribute '" + name + "' must be float32x3 or float32x4, got " + a.format)
582
+ }
583
+ if (!position && a.format !== "float32") {
584
+ throw new Error(site + ": instanceOrder field attribute '" + name + "' must be float32, got " + a.format)
585
+ }
586
+ if (at % 4 !== 0) {
587
+ throw new Error(site + ": instanceOrder key attribute '" + name + "' starts at byte " + at + " of its record; the key must start on a 4-byte boundary")
588
+ }
589
+ return {
590
+ key: layoutKey(b.attributes),
591
+ floats: at / 4,
592
+ position,
593
+ descending: order.descending === true,
594
+ retain: position && (order as { retain?: boolean }).retain === true,
595
+ indices: null,
596
+ }
597
+ }
598
+ at += VERTEX_FORMATS[a.format].bytes
599
+ }
600
+ }
601
+ throw new Error(site + ": instanceOrder names attribute '" + name + "', which no instance buffer of the material declares")
602
+ }
603
+
604
+ /**
605
+ * Whether a material still carries the layout an ordered mesh keys on
606
+ * (the key stream must be one of the buffers the entry binds). Throws
607
+ * naming `site`; checked beside the instance pairing at add().
608
+ */
609
+ export function checkOrderPairing(material: Material, inst: MeshInstances, site: string): void {
610
+ if (inst.order === null) return
611
+ let buffers = material.instanceBuffers ?? []
612
+ let key = inst.order.key
613
+ if (!buffers.some(b => layoutKey(b.attributes) === key)) {
614
+ throw new Error(site + ": the material declares no instance buffer with the mesh's instanceOrder key layout (" + key + ")")
615
+ }
616
+ }
617
+
383
618
  /** What both population kinds take. */
384
619
  type PopulationOptions = {
385
620
  /** LOCAL bounds covering the population ([minX, minY, minZ, maxX, maxY,
@@ -392,6 +627,10 @@ type PopulationOptions = {
392
627
  * follows them) and sorts by its node position; a record mesh is never
393
628
  * culled and never picked. */
394
629
  bounds?: ArrayLike<number>
630
+ /** Draw the records in key order, decided core-side (see
631
+ * InstanceOrderOptions): back-to-front for a transparent population, an
632
+ * explicit sort field for a JS-stepped one. Fixed at creation. */
633
+ instanceOrder?: InstanceOrderOptions
395
634
  /** Debug label for the record buffer. */
396
635
  label?: string
397
636
  }
@@ -421,7 +660,20 @@ export type InstancedMeshOptions = PopulationOptions & {
421
660
  /** A record mesh's bounds are also its ONLY picking leaf: records are
422
661
  * opaque data, so without them the mesh never picks and pointer events
423
662
  * never target it. */
424
- export type RecordMeshOptions = PopulationOptions
663
+ export type RecordMeshOptions = PopulationOptions & {
664
+ /** Transfer the records to the engine at creation - the write-once
665
+ * form for a large population (a baked splat cloud): the engine keeps
666
+ * the one CPU-side copy, and the mesh keeps NO mirror, so the records
667
+ * are resident once instead of twice and the creation bytes are free
668
+ * to drop the moment this returns. The population is write-once by
669
+ * contract: setRecords, updateRecords, instanceAttribute and growth
670
+ * throw; setRecordCount (an ordered mesh republishes the prefix
671
+ * core-side), setVisible, transforms, re-attach and disposeInstances
672
+ * all still work. Takes a single-stream material; with an
673
+ * instanceOrder the engine's retained copy is implied (`retain` is
674
+ * forced on). Default false. */
675
+ transfer?: boolean
676
+ }
425
677
 
426
678
  function copyBounds(bounds: ArrayLike<number> | undefined, site: string): Float32Array | null {
427
679
  if (bounds === undefined) return null
@@ -522,6 +774,10 @@ export function createInstancedMesh(geometry: Geometry, material: Material, opts
522
774
  anchor: opts?.anchor ?? null,
523
775
  levels: null,
524
776
  morph: populationMorph(geometry, capacity, opts?.label),
777
+ order: resolveInstanceOrder(opts?.instanceOrder, buffers, "createInstancedMesh"),
778
+ transfer: false,
779
+ group: 1,
780
+ textures: [],
525
781
  }
526
782
  return mesh
527
783
  }
@@ -602,6 +858,7 @@ export function setInstanceStyle(instance: InstanceNode, values: ArrayLike<numbe
602
858
  export function instanceAttribute(mesh: InstancedMesh | RecordMesh, name: string): AttributeAccess | null {
603
859
  let inst: MeshInstances | null = mesh._instances
604
860
  if (inst === null) throw new Error("instanceAttribute: the mesh's instances are disposed")
861
+ if (inst.transfer) throw new Error("instanceAttribute: the records were transferred at creation (transfer: true) - there is no mirror to read or write")
605
862
  for (let s of inst.streams) {
606
863
  let i = s.layout.findIndex(a => a.name === name)
607
864
  if (i >= 0) return s._fields[i]!
@@ -625,6 +882,7 @@ export type UpdateRecordsOptions = { stream?: number; first?: number; count?: nu
625
882
  export function updateRecords(mesh: InstancedMesh | RecordMesh, options: UpdateRecordsOptions = {}): void {
626
883
  let inst: MeshInstances | null = mesh._instances
627
884
  if (inst === null) throw new Error("updateRecords: the mesh's instances are disposed")
885
+ if (inst.transfer) throw new Error("updateRecords: the records were transferred at creation (transfer: true) - there is no mirror to publish from")
628
886
  let index = options.stream ?? 0
629
887
  let stream = inst.streams[index]
630
888
  if (!Number.isInteger(index) || stream === undefined) {
@@ -639,10 +897,25 @@ export function updateRecords(mesh: InstancedMesh | RecordMesh, options: UpdateR
639
897
  }
640
898
 
641
899
  /** Publish a mesh's pending record writes, one buffer write per dirty
642
- * stream (the scene calls it from its sync). */
900
+ * stream (the scene calls it from its sync). An ordered mesh publishes
901
+ * the LIVE record set whole through the write lease - the engine gathers
902
+ * it into key order during the copy, and a byte range has no stable
903
+ * position under a permutation (writeBuffer throws on an ordered buffer). */
643
904
  export function publishRecords(mesh: Mesh): void {
644
905
  let inst = mesh._instances
645
906
  if (inst === null) return
907
+ if (inst.order !== null) {
908
+ for (let s of inst.streams) {
909
+ if (s.dirty === null) continue
910
+ s.dirty = null
911
+ let bytes = inst.count * s.stride
912
+ if (bytes === 0) continue
913
+ let block = beginBufferWrite(s.buffer)
914
+ new Uint8Array(block.buffer, 0, bytes).set(vertexBytes(s.data).subarray(0, bytes))
915
+ endBufferWrite(s.buffer, bytes)
916
+ }
917
+ return
918
+ }
646
919
  for (let s of inst.streams) {
647
920
  if (s.dirty === null) continue
648
921
  let [lo, hi] = s.dirty
@@ -651,6 +924,18 @@ export function publishRecords(mesh: Mesh): void {
651
924
  }
652
925
  }
653
926
 
927
+ /** @internal Mark every stream's live records dirty, so the next sync
928
+ * republishes the population whole: an ordered mesh (re-)entering a
929
+ * scene seeds the entry's freshly built engine-side order registry from
930
+ * the JS mirrors. */
931
+ export function markLiveRecords(mesh: Mesh): void {
932
+ let inst = mesh._instances
933
+ // A transferred mesh has no mirror; the engine's own (buffer-lifetime)
934
+ // mirror seeds a re-attached entry instead.
935
+ if (inst === null || inst.count === 0 || inst.transfer) return
936
+ for (let s of inst.streams) markRecords(mesh, s, 0, inst.count * s.stride)
937
+ }
938
+
654
939
  /**
655
940
  * Add an instance to an instanced mesh: a scene node placed by `update`
656
941
  * (position/rotation/quaternion/scale; absent keys at their identity)
@@ -739,6 +1024,7 @@ export function reparentInstance(instance: InstanceNode, parent: SceneNode): voi
739
1024
  // are freed.
740
1025
  function growInstances(mesh: InstancedMesh | RecordMesh, next: number): void {
741
1026
  let inst: MeshInstances = mesh._instances
1027
+ if (inst.transfer) throw new Error("growInstances: a transferred population is fixed at its creation records")
742
1028
  let previous = inst.capacity
743
1029
  inst.capacity = next
744
1030
  let freed: BufferId[] = []
@@ -785,11 +1071,42 @@ export function createRecordMesh(
785
1071
  opts?: RecordMeshOptions,
786
1072
  ): RecordMesh {
787
1073
  let buffers = instancedBuffers(material, "createRecordMesh")
788
- let capacity = recordCount(records, buffers[0]!.attributes, "createRecordMesh")
1074
+ let order = resolveInstanceOrder(opts?.instanceOrder, buffers, "createRecordMesh")
1075
+ let indices = order?.indices ?? null
1076
+ if (indices !== null && opts?.transfer === false) {
1077
+ throw new Error("createRecordMesh: an indexed instanceOrder hands the records to the engine by nature; transfer cannot be false")
1078
+ }
1079
+ let transfer = opts?.transfer === true || indices !== null
1080
+ if (transfer && buffers.length !== 1) {
1081
+ throw new Error(
1082
+ "createRecordMesh: transfer takes a single-stream material; this one declares " + buffers.length + " instance buffers (further streams need the partial writes transfer gives up)",
1083
+ )
1084
+ }
1085
+ // Under an indexed order the records are laid out by the order's record
1086
+ // layout, not the material's (whose one buffer is the id stream).
1087
+ let capacity: number
1088
+ if (indices !== null) {
1089
+ if (records.byteLength === 0 || records.byteLength % indices.stride !== 0) {
1090
+ throw new Error("createRecordMesh: records hold " + records.byteLength + " bytes, not a positive whole number of " + indices.stride + "-byte records")
1091
+ }
1092
+ capacity = records.byteLength / indices.stride
1093
+ } else {
1094
+ capacity = recordCount(records, buffers[0]!.attributes, "createRecordMesh")
1095
+ }
1096
+ // A transferred order always retains: the engine's mirror is the ONE
1097
+ // record copy, what re-attaches seed from and count changes republish
1098
+ // (a field key gets the same retention, engine-side only).
1099
+ if (transfer && order !== null && !order.retain) order = { ...order, retain: true }
789
1100
  let mesh = createMesh(geometry, material) as RecordMesh
1101
+ let group = indices !== null ? layoutStride(buffers[0]!.attributes) / 4 : 1
790
1102
  mesh._instances = {
791
1103
  matrix: null,
792
- streams: buffers.map((b, i) => makeStream(b.attributes, capacity, null, streamLabel(opts?.label, i))),
1104
+ streams:
1105
+ indices !== null
1106
+ ? [makeIndexStream(buffers[0]!.attributes, capacity, group, vertexBytes(records), streamLabel(opts?.label, 0))]
1107
+ : transfer
1108
+ ? [makeTransferredStream(buffers[0]!.attributes, vertexBytes(records), order !== null, streamLabel(opts?.label, 0))]
1109
+ : buffers.map((b, i) => makeStream(b.attributes, capacity, null, streamLabel(opts?.label, i))),
793
1110
  capacity,
794
1111
  label: opts?.label,
795
1112
  count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
@@ -798,8 +1115,12 @@ export function createRecordMesh(
798
1115
  anchor: null,
799
1116
  levels: null,
800
1117
  morph: null,
1118
+ order,
1119
+ transfer,
1120
+ group,
1121
+ textures: [],
801
1122
  }
802
- copyRecords(mesh, records, capacity)
1123
+ if (!transfer) copyRecords(mesh, records, capacity)
803
1124
  return mesh
804
1125
  }
805
1126
 
@@ -829,6 +1150,7 @@ function copyRecords(mesh: RecordMesh, records: ArrayBufferView, written: number
829
1150
  */
830
1151
  export function setRecords(mesh: RecordMesh, records: ArrayBufferView, count?: number): void {
831
1152
  let inst = mesh._instances
1153
+ if (inst.transfer) throw new Error("setRecords: the records were transferred at creation (transfer: true) - the population is write-once; mount a new mesh for new records")
832
1154
  let written = recordCount(records, inst.streams[0]!.layout, "setRecords")
833
1155
  if (written > inst.capacity) growInstances(mesh, Math.max(written, inst.capacity * 2))
834
1156
  copyRecords(mesh, records, written)
@@ -842,6 +1164,12 @@ export function setRecordCount(mesh: RecordMesh, count: number): void {
842
1164
  let n = Math.max(0, Math.min(Math.floor(count), inst.capacity))
843
1165
  if (n === inst.count) return
844
1166
  inst.count = n
1167
+ // Ordered records draw the first n GATHERED records, so a count change
1168
+ // republishes the live set - a shrink would otherwise keep drawing the
1169
+ // old population's nearest records, a growth its stale tail. A
1170
+ // transferred mesh has no mirror to publish from; the engine follows
1171
+ // the entry's instance count into its own mirror's prefix instead.
1172
+ if (inst.order !== null && !inst.transfer && n > 0) for (let s of inst.streams) markRecords(mesh, s, 0, n * s.stride)
845
1173
  mesh._scene?._setCount(mesh)
846
1174
  }
847
1175
 
@@ -882,6 +1210,7 @@ export function disposeInstances(mesh: InstancedMesh | RecordMesh): void {
882
1210
  if (inst.matrix !== null) destroyBuffer(inst.matrix)
883
1211
  for (let s of inst.streams) destroyBuffer(s.buffer)
884
1212
  if (inst.morph !== null) destroyTexture(inst.morph.texture)
1213
+ for (let t of inst.textures) destroyTexture(t)
885
1214
  ;(mesh as Mesh)._instances = null
886
1215
  }
887
1216
 
@@ -919,6 +1248,32 @@ export function setCastShadow(mesh: Mesh, cast: boolean): void {
919
1248
  for (let l of mesh._instances?.levels ?? []) if (l !== mesh) setCastShadow(l, cast)
920
1249
  }
921
1250
 
1251
+ /**
1252
+ * Give the mesh an environment of its own, or none: `null` removes the
1253
+ * environment term entirely - a sealed interior under an outdoor sky,
1254
+ * where light `layers` mask the sun but cannot mask image lighting -
1255
+ * `{ cube, intensity?, rotation? }` samples that cube instead of the
1256
+ * scene's (an interior probe's chain, a baked room), and `undefined`
1257
+ * (the default) follows the scene's, live through scene.setEnvironment.
1258
+ * Unity's reflectionProbeUsage Off and Three's `material.envMap`, per
1259
+ * MESH, because materials are shared and interiors are mesh-grained
1260
+ * here. Applied where the material reflects at all (Material.env); a
1261
+ * no-op on any other material. The mesh's entries are rebuilt, so this
1262
+ * is a state change (a camera crossing a doorway), not a frame-rate
1263
+ * dial. A reflection probe drawing this mesh never samples its own cube
1264
+ * through it (the black placeholder stands in, the same-pass rule).
1265
+ */
1266
+ export function setEnvironment(mesh: Mesh, environment: EnvironmentOptions | null | undefined): void {
1267
+ let next = environment == null ? environment : { cube: environment.cube, ...checkCubeKnobs(environment, "setEnvironment") }
1268
+ let a = mesh.environment
1269
+ let same = next == null || a == null ? next === a : next.cube === a.cube && next.intensity === a.intensity && next.rotation === a.rotation
1270
+ if (same) return
1271
+ mesh.environment = next
1272
+ rebuildEntry(mesh)
1273
+ // An instanced LOD's levels reflect what the population reflects.
1274
+ for (let l of mesh._instances?.levels ?? []) if (l !== mesh) setEnvironment(l, environment)
1275
+ }
1276
+
922
1277
  /** Frustum culling per mesh: `frustumCulled` (default true) switches the
923
1278
  * gate, `cullMargin` (world units, default 0) grows the tested box. */
924
1279
  export function setCulling(mesh: Mesh, options: { frustumCulled?: boolean; cullMargin?: number }): void {
package/src/model-data.ts CHANGED
@@ -2,19 +2,21 @@
2
2
  // runtime: the glTF parser, the .srtm container and the pure geometry kit,
3
3
  // for a script that bakes an app's own geometry under bun (or any host
4
4
  // with no flux). The package root imports `flux:*` and so loads only on
5
- // the runtime; this entry imports nothing that does. tools/model.ts (the
6
- // glTF bake) runs on exactly this surface, so an app's bake script and
7
- // the tool share one path:
5
+ // the runtime; this entry imports nothing that does. tools/model.flux.ts
6
+ // (the glTF bake) takes its model side from exactly this surface, so an
7
+ // app's bake script and the tool share one path:
8
8
  //
9
9
  // import { box, encodeModel, transformGeometry } from "@solidrt/3d/model"
10
10
  // writeFileSync("assets/terrain.srtm", encodeModel({ nodes, parts, ... }))
11
11
  //
12
12
  // createModel / loadModel on the runtime read what it writes. Keep this
13
- // entry pure: tests/model-data.test.ts imports it under bun and fails the
14
- // moment a runtime import creeps into the chain.
13
+ // entry pure. tests/model-data.test.ts imports it on the bare flux binary
14
+ // and fails the moment a gui or srt: import creeps into the chain; a
15
+ // headless flux: import it would not notice, and a bake script under bun
16
+ // would.
15
17
 
16
- export { gltfExternalUris, isGlb, parseGltf } from "./gltf.ts"
17
- export type { ModelChannel, ModelClip, ModelData, ModelExtras, ModelMaterial, ModelNode, ModelPart, ModelSkin, UriResolver } from "./gltf.ts"
18
+ export { gltfExternalUris, isGlb, modelImageUses, parseGltf } from "./gltf.ts"
19
+ export type { ModelChannel, ModelClip, ModelData, ModelExtras, ModelImage, ModelImageUse, ModelMaterial, ModelNode, ModelPart, ModelSkin, ModelTexture, UriResolver } from "./gltf.ts"
18
20
  export { decodeModel, encodeModel } from "./model-file.ts"
19
21
  export { arrowHelper, axesHelper, box, box3Helper, capsule, capsuleHelper, circle, cone, cylinder, dodecahedron, fillAttribute, fillColors, edgesGeometry, mergeVertices, normalsHelper, toNonIndexed, withNormals, geometryBounds, geometryTopology, gridHelper, icosahedron, attributeAccess, geometryAttribute, geometryKey, geometryLayouts, geometrySlot, geometryStreams, geometryVertexCount, isFloatFormat, isFloatLayout, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, octahedron, packGeometry, packMorphTargets, plane, planeHelper, polyhedron, ring, sphere, tetrahedron, torus, torusKnot, transformGeometry, validateGeometry, wireframeGeometry, vertexBytes, vertexCount, vertexView, withAttribute, withColors, withMorphTargets, MORPH_ENTRY_TEXELS, MORPH_TEXEL_FLOATS, BASE_FLOATS, VERTEX_FORMATS, VERTEX_LAYOUTS } from "./geometry.ts"
20
22
  export type { ArrowHelperOptions, AttributeAccess, AttributeFill, VertexStream, WithAttributeOptions, AxesHelperOptions, BoxOptions, CapsuleHelperOptions, CapsuleOptions, CircleOptions, ColorFill, ConeOptions, CylinderOptions, FormatCodec, Geometry, GeometryOptions, GridHelperOptions, MorphTarget, MorphTargets, NormalsHelperOptions, PlaneHelperOptions, PlaneOptions, PolyhedronOptions, RingOptions, SphereOptions, TorusKnotOptions, TorusOptions, VertexLayout } from "./geometry.ts"
package/src/model-file.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  // The baked model container (.srtm): ModelData as one file whose payload IS
2
2
  // the GPU layout, so loading it is a header parse plus typed-array views -
3
- // no per-vertex work. Written by tools/model.ts under bun (from parseGltf),
4
- // read by loadModel on flux; both ends are this pure module.
3
+ // no per-vertex work. Written by tools/model.flux.ts (from parseGltf),
4
+ // read by loadModel on flux; both ends are this pure module. An image
5
+ // block carries the image file as it is, a PNG, a JPEG or a KTX2, under
6
+ // the image's name.
5
7
  //
6
8
  // Layout, all little-endian:
7
9
  // "SRTM" u32 | version u32 | jsonLength u32 | json (padded to 4) | payload
@@ -16,7 +18,7 @@
16
18
  // flipped index order) share the vertex block - decodeModel restores the
17
19
  // same identities, so the runtime uploads shared bytes once.
18
20
 
19
- import type { ModelChannel, ModelData, ModelExtras, ModelMaterial, ModelNode, ModelSkin } from "./gltf.ts"
21
+ import type { ModelChannel, ModelData, ModelExtras, ModelImage, ModelMaterial, ModelNode, ModelSkin } from "./gltf.ts"
20
22
  import { layoutStride, vertexView, VERTEX_FORMATS } from "./geometry.ts"
21
23
  import type { VertexAttribute } from "@solidrt/core/gpu"
22
24
  import type { Geometry, VertexLayout } from "./geometry.ts"
@@ -54,7 +56,10 @@ const MAGIC = 0x4d545253
54
56
  // every earlier version rather than read as a file without them, so a
55
57
  // stale bake never silently drops the data an app relies on. Re-bake
56
58
  // with `srt tool 3d/model`.
57
- const VERSION = 10
59
+ // Version 11 names the images: an image record is its block and the
60
+ // image's name (ModelImage.name), which labels its texture. A
61
+ // version-10 file has blocks alone and is rejected the same way.
62
+ const VERSION = 11
58
63
 
59
64
  // The named layouts the container writes by name; a custom attribute-list
60
65
  // layout (a skinned primitive with COLOR_0, a withAttribute channel) is
@@ -76,6 +81,10 @@ function decodeLayout(layout: string | VertexAttribute[], name: string): VertexL
76
81
 
77
82
  type Block = { offset: number; bytes: number }
78
83
 
84
+ /** One image: the block holding its file, and its name. The uri a parse
85
+ * read it from is not written: the container embeds the image. */
86
+ type ImageHeader = Block & { name: string }
87
+
79
88
  /** One geometry of the table: its interleaved vertex block, index block
80
89
  * and packed morph targets. Blocks may be shared between entries. */
81
90
  type GeometryHeader = {
@@ -116,7 +125,7 @@ type ClipHeader = { name: string; duration: number; channels: ChannelHeader[] }
116
125
  type Header = {
117
126
  nodes: ModelNode[]
118
127
  materials: ModelMaterial[]
119
- images: Block[]
128
+ images: ImageHeader[]
120
129
  geometries: GeometryHeader[]
121
130
  parts: PartHeader[]
122
131
  skins: SkinHeader[]
@@ -190,7 +199,7 @@ export function encodeModel(data: ModelData): Uint8Array {
190
199
  if (part.extras !== undefined) header.extras = part.extras
191
200
  return header
192
201
  })
193
- let images = data.images.map((image) => push(image))
202
+ let images: ImageHeader[] = data.images.map((image) => ({ ...push(image.bytes), name: image.name }))
194
203
  let floats = (f: Float32Array): Block => push(new Uint8Array(f.buffer, f.byteOffset, f.byteLength))
195
204
  let skins: SkinHeader[] = data.skins.map((skin) => ({ joints: skin.joints, inverseBind: floats(skin.inverseBind), jointBounds: floats(skin.jointBounds) }))
196
205
  let clips: ClipHeader[] = data.clips.map((clip) => ({
@@ -277,7 +286,7 @@ export function decodeModel(bytes: Uint8Array): ModelData {
277
286
  if (part.extras !== undefined) out.extras = part.extras
278
287
  return out
279
288
  })
280
- let images = header.images.map((block) => new Uint8Array(buffer, payload + block.offset, block.bytes))
289
+ let images: ModelImage[] = header.images.map((image) => ({ bytes: new Uint8Array(buffer, payload + image.offset, image.bytes), name: image.name }))
281
290
  let floats = (block: Block): Float32Array => new Float32Array(buffer, payload + block.offset, block.bytes / 4)
282
291
  let skins: ModelSkin[] = header.skins.map((skin) => ({ joints: skin.joints, inverseBind: floats(skin.inverseBind), jointBounds: floats(skin.jointBounds) }))
283
292
  let clips = header.clips.map((clip) => ({