@solidrt/3d 0.0.69 → 0.0.71

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 CHANGED
@@ -629,6 +629,16 @@ or fixed is decided at mount; `output` requires explicit sizes (the
629
629
  target cannot follow a leaf it does not own), and giving exactly one of
630
630
  width/height throws.
631
631
 
632
+ The fill leaf is size-contained (`contain="size"`, the CSS layout prop
633
+ every element takes): it adds nothing to its ancestors' sizes, exactly as
634
+ an empty `<view>` at 100% would. So a column of header, `<Scene>` pane
635
+ (`flexGrow: 1`) and footer lays out at the window's height with no
636
+ `minHeight={0}` anywhere, and a pane with no definite height does not
637
+ come out square. The price is that the leaf has no size of its own at
638
+ all: a parent with no size leaves it empty, the scene is invisible, and
639
+ the component warns once with the stack of the component that built it,
640
+ naming the fix (a size, or flexGrow in a sized column).
641
+
632
642
  Fixed sizes still matter where the target is a measured quantity: probes
633
643
  and checks that snapshot exact pixels, supersampling via `output`, or a
634
644
  scene composited at a size unrelated to its layout. `width`/`height` are
@@ -1420,7 +1430,10 @@ makes an INSTANCED material: the vertex stage reads the attributes as
1420
1430
  `in` variables beside the layout's own, and each drawn instance gets one
1421
1431
  record from each of the mesh's instance buffers. An instance buffer IS a
1422
1432
  vertex stream stepped per instance: on the mesh it is an `InstanceStream`
1423
- (the layout, a byte mirror, a mesh-owned GPU buffer), written through
1433
+ (the layout over the core's record stream - `createRecordStream` in
1434
+ `@solidrt/core/gpu`: the GPU buffer, the byte mirror, the dirty range
1435
+ and the range-or-whole publish, the one model @solidrt/2d's layers run
1436
+ on too), written through
1424
1437
  `instanceAttribute(mesh, name)` - the accessor `geometryAttribute`
1425
1438
  returns, record index in, values as the shader sees them, the codec
1426
1439
  packing the bytes - or in bulk through the mirror itself, `records(mesh,
@@ -3068,8 +3081,8 @@ successors) as ordinary content, the model-loading split repeated:
3068
3081
  rebuild.
3069
3082
  - Record writes are mirrored, not immediate: `setInstanceStyle`,
3070
3083
  `setRecords` and an `instanceAttribute` write land in the stream's
3071
- `data` and the scene's sync publishes the `dirty` range (`updateRecords`
3072
- marks it for accessor writes) - so a write on a mesh outside any scene
3084
+ `data` (the core record stream's mirror) and the scene's sync publishes
3085
+ its dirty range (`updateRecords` marks it for accessor writes) - so a write on a mesh outside any scene
3073
3086
  shows once it is added (the dirty range waits), and reading the GPU
3074
3087
  buffer back between the write and the sync lags the mirror. The sync
3075
3088
  runs in the publish pass of core's `onBeforeRender`, ahead of the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.69",
3
+ "version": "0.0.71",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -20,10 +20,10 @@
20
20
  "AGENTS.md"
21
21
  ],
22
22
  "devDependencies": {
23
- "@solidrt/test": "0.0.69"
23
+ "@solidrt/test": "0.0.71"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "@solidjs/signals": "2.0.0-rc.13",
27
- "@solidrt/core": "0.0.69"
27
+ "@solidrt/core": "0.0.71"
28
28
  }
29
29
  }
@@ -1,4 +1,4 @@
1
- import { createEffect, createRenderEffect, createSignal, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onLayout, untrack } from "@solidrt/core"
1
+ import { createEffect, createRenderEffect, createSignal, displayScale, getBoundingBoxViewport, getLayoutBox, onCleanup, onLayout, untrack, warnOnce } from "@solidrt/core"
2
2
  import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
3
3
  import { SceneContext } from "./context.tsx"
4
4
  import { createScene } from "../scene.ts"
@@ -311,11 +311,22 @@ export let Scene: ParentComponent<SceneProps> = props => {
311
311
  // onLayout handler and as an effect apply, both untracked scopes, so
312
312
  // its scale read is wrapped in an explicit untrack (the effect's
313
313
  // compute is what tracks it).
314
+ let warnEmpty = warnOnce()
314
315
  let apply = () =>
315
316
  untrack(() => {
316
317
  if (!leafNode) return
317
318
  let box = getBoundingBoxViewport(leafNode)
318
319
  if (!box) return
320
+ // The leaf adds no size of its own (contain="size" below), so a
321
+ // parent with no size leaves it empty: nothing renders, and nothing
322
+ // on screen says why.
323
+ if (box.width === 0 || box.height === 0) {
324
+ warnEmpty(
325
+ `Scene: the fill leaf laid out ${box.width} x ${box.height}, so the scene is invisible. ` +
326
+ `It adds no size of its own: give its parent a size (width/height, or flexGrow in a sized column), as on the web.`,
327
+ )
328
+ return
329
+ }
319
330
  let scale = displayScale()
320
331
  scene.setSize(Math.max(1, Math.round(box.width * scale)), Math.max(1, Math.round(box.height * scale)))
321
332
  })
@@ -325,6 +336,11 @@ export let Scene: ParentComponent<SceneProps> = props => {
325
336
  // Pointer events on the built-in leaf: at target size the plain
326
337
  // handlers, in fill mode scaled from the laid-out box.
327
338
  let sceneHandlers = fill ? scene.handlersFor(builtinLayout) : scene.handlers
339
+ // The fill leaf is size-contained: the target follows the leaf's box, so
340
+ // the target's pixels must never come back as the leaf's intrinsic size
341
+ // (the flex automatic minimum would pin every ancestor to the target, and
342
+ // an indefinite height would come out square). The leaf adds nothing to
343
+ // its ancestors, exactly as an empty view at 100% would.
328
344
  return (
329
345
  <SceneContext value={{ scene, parent: scene.root, viewport: scene, pointer }}>
330
346
  {output ? (
@@ -335,6 +351,7 @@ export let Scene: ParentComponent<SceneProps> = props => {
335
351
  src={scene.texture}
336
352
  width={fill ? "100%" : props.width}
337
353
  height={fill ? "100%" : props.height}
354
+ contain={fill ? "size" : undefined}
338
355
  onPointerDown={events ? sceneHandlers.onPointerDown : undefined}
339
356
  onPointerMove={events ? sceneHandlers.onPointerMove : undefined}
340
357
  onPointerUp={events ? sceneHandlers.onPointerUp : undefined}
package/src/mesh.ts CHANGED
@@ -3,8 +3,8 @@
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 { beginBufferWrite, createBuffer, destroyBuffer, destroyTexture, endBufferWrite, transferRecords, writeBuffer } from "@solidrt/core/gpu"
7
- import type { BufferId, DrawId, ShaderParams, TextureBindings, TextureId, VertexAttribute, VertexBufferLayout } from "@solidrt/core/gpu"
6
+ import { createBuffer, createRecordStream, destroyBuffer, destroyTexture, transferRecords } from "@solidrt/core/gpu"
7
+ import type { BufferId, DrawId, RecordStream, ShaderParams, TextureBindings, TextureId, VertexAttribute, VertexBufferLayout } from "@solidrt/core/gpu"
8
8
  import { checkCubeKnobs } from "./environment.ts"
9
9
  import type { EnvironmentOptions } from "./environment.ts"
10
10
  import { geometryBounds, isFloatLayout, layoutKey, layoutStride, plane, vertexBytes, vertexView, VERTEX_FORMATS } from "./geometry.ts"
@@ -189,15 +189,22 @@ export type InstanceStream = {
189
189
  layout: VertexAttribute[]
190
190
  /** Bytes per record: the layout's stride. */
191
191
  stride: number
192
+ /** The GPU buffer the entry binds; `stream.buffer` where a stream
193
+ * exists (growth re-points both), the engine-held buffer otherwise. */
192
194
  buffer: BufferId
195
+ /** The core record stream behind the mirror (createRecordStream: the
196
+ * buffer, the byte mirror, the dirty range and the publish), or null
197
+ * where no JS mirror exists: the transfer form hands the records to
198
+ * the engine, the indexed order's id stream is the engine's to fill. */
199
+ stream: RecordStream | null
200
+ /** The mirror as the layout's view (a Float32Array over an all-float
201
+ * layout, bytes otherwise): `stream.bytes` viewed, re-derived on growth;
202
+ * zero-length without a stream. */
193
203
  data: ArrayBufferView
194
204
  /** The record a fresh (or recycled) instance slot starts with: the
195
205
  * material's instanceStyle encoded, on the first stream of an
196
206
  * instanced mesh; zeros otherwise. */
197
207
  blank: Uint8Array
198
- /** The byte range [lo, hi) of `data` written since the last publish,
199
- * or null. */
200
- dirty: [number, number] | null
201
208
  /** The DataView the accessors read through; follows `data`. */
202
209
  _view: DataView
203
210
  /** All-float32 layout: `data` is a Float32Array and a record write is
@@ -384,23 +391,25 @@ function streamLabel(label: string | undefined, index: number): string | undefin
384
391
  return label === undefined ? undefined : label + "-" + index
385
392
  }
386
393
 
387
- // A stream of `capacity` zeroed records over `layout`; its GPU buffer is
388
- // sized and unwritten (the mirror is the truth, publishes follow dirty).
394
+ // A stream of `capacity` zeroed records over `layout` on a core record
395
+ // stream; its GPU buffer is sized and unwritten (the mirror is the truth,
396
+ // publishes follow the dirty range).
389
397
  function makeStream(layout: VertexAttribute[], capacity: number, blank: Uint8Array | null, label: string | undefined): InstanceStream {
390
398
  let stride = layoutStride(layout)
399
+ let records = createRecordStream(stride, capacity, { autoFree: false, label })
391
400
  let stream: InstanceStream = {
392
401
  layout,
393
402
  stride,
394
- buffer: createBuffer(capacity * stride, { autoFree: false, label }),
403
+ buffer: records.buffer,
404
+ stream: records,
395
405
  data: new Uint8Array(0),
396
406
  blank: blank ?? new Uint8Array(stride),
397
- dirty: null,
398
407
  _view: new DataView(new ArrayBuffer(0)),
399
408
  _floats: isFloatLayout(layout),
400
409
  _components: layoutComponents(layout),
401
410
  _fields: [],
402
411
  }
403
- viewStream(stream, new ArrayBuffer(capacity * stride))
412
+ viewStream(stream, records.bytes.buffer)
404
413
  stream._fields = streamFields(stream)
405
414
  return stream
406
415
  }
@@ -416,9 +425,9 @@ function makeIndexStream(layout: VertexAttribute[], capacity: number, group: num
416
425
  layout,
417
426
  stride,
418
427
  buffer: createBuffer(Math.ceil(capacity / group) * stride, { autoFree: false, label }),
428
+ stream: null,
419
429
  data: new Uint8Array(0),
420
430
  blank: new Uint8Array(stride),
421
- dirty: null,
422
431
  _view: new DataView(new ArrayBuffer(0)),
423
432
  _floats: false,
424
433
  _components: layoutComponents(layout),
@@ -442,9 +451,9 @@ function makeTransferredStream(layout: VertexAttribute[], records: Uint8Array, o
442
451
  buffer: ordered
443
452
  ? createBuffer(records.byteLength, { autoFree: false, label })
444
453
  : createBuffer(records, { autoFree: false, label }),
454
+ stream: null,
445
455
  data: new Uint8Array(0),
446
456
  blank: new Uint8Array(stride),
447
- dirty: null,
448
457
  _view: new DataView(new ArrayBuffer(0)),
449
458
  _floats: isFloatLayout(layout),
450
459
  _components: layoutComponents(layout),
@@ -475,21 +484,18 @@ function writeRecord(stream: InstanceStream, i: number, values: ArrayLike<number
475
484
  // One record of `layout` encoded from `values`, the bytes a blank slot copies.
476
485
  function encodeRecord(layout: VertexAttribute[], values: ArrayLike<number>, site: string): Uint8Array {
477
486
  let stride = layoutStride(layout)
478
- let one: InstanceStream = { layout, stride, buffer: 0 as BufferId, data: new Uint8Array(0), blank: new Uint8Array(0), dirty: null, _view: new DataView(new ArrayBuffer(0)), _floats: isFloatLayout(layout), _components: layoutComponents(layout), _fields: [] }
487
+ let one: InstanceStream = { layout, stride, buffer: 0 as BufferId, stream: null, data: new Uint8Array(0), blank: new Uint8Array(0), _view: new DataView(new ArrayBuffer(0)), _floats: isFloatLayout(layout), _components: layoutComponents(layout), _fields: [] }
479
488
  viewStream(one, new ArrayBuffer(stride))
480
489
  one._fields = streamFields(one)
481
490
  writeRecord(one, 0, values, site)
482
491
  return vertexBytes(one.data)
483
492
  }
484
493
 
485
- // Extend a stream's dirty range over [lo, hi) bytes of the mirror and ask
486
- // the scene to publish it at the next sync.
494
+ // Mark records [lo, hi) of a stream's mirror and ask the scene to publish
495
+ // them at the next sync.
487
496
  function markRecords(mesh: Mesh, stream: InstanceStream, lo: number, hi: number): void {
488
- if (stream.dirty === null) stream.dirty = [lo, hi]
489
- else {
490
- if (lo < stream.dirty[0]) stream.dirty[0] = lo
491
- if (hi > stream.dirty[1]) stream.dirty[1] = hi
492
- }
497
+ if (stream.stream === null) throw new Error("markRecords: the stream has no mirror (its records were transferred to the engine)")
498
+ stream.stream.mark(lo, hi)
493
499
  mesh._scene?._setRecords(mesh)
494
500
  }
495
501
 
@@ -869,7 +875,7 @@ export function setInstanceStyle(instance: InstanceNode, values: ArrayLike<numbe
869
875
  let stream = l._instances.streams[0]
870
876
  if (stream === undefined) continue
871
877
  writeRecord(stream, instance._slot, values, "setInstanceStyle")
872
- markRecords(l, stream, instance._slot * stream.stride, (instance._slot + 1) * stream.stride)
878
+ markRecords(l, stream, instance._slot, instance._slot + 1)
873
879
  }
874
880
  }
875
881
 
@@ -944,34 +950,23 @@ export function updateRecords(mesh: InstancedMesh | RecordMesh, options: UpdateR
944
950
  if (!Number.isInteger(first) || !Number.isInteger(count) || first < 0 || count < 0 || first + count > inst.capacity) {
945
951
  throw new Error("updateRecords: range [" + first + ", " + (first + count) + ") is outside the mesh's " + inst.capacity + " records")
946
952
  }
947
- if (count > 0) markRecords(mesh, stream, first * stream.stride, (first + count) * stream.stride)
953
+ if (count > 0) markRecords(mesh, stream, first, first + count)
948
954
  }
949
955
 
950
956
  /** Publish a mesh's pending record writes, one buffer write per dirty
951
- * stream (the scene calls it from its sync). An ordered mesh publishes
952
- * the LIVE record set whole through the write lease - the engine gathers
953
- * it into key order during the copy, and a byte range has no stable
954
- * position under a permutation (writeBuffer throws on an ordered buffer). */
957
+ * stream (the scene calls it from its sync): the dirty record range, up
958
+ * to the capacity (a record mesh writes ahead of its count). An ordered
959
+ * mesh publishes the LIVE record set whole through the write lease - the
960
+ * engine gathers it into key order during the copy, and a byte range has
961
+ * no stable position under a permutation (writeBuffer throws on an
962
+ * ordered buffer). */
955
963
  export function publishRecords(mesh: Mesh): void {
956
964
  let inst = mesh._instances
957
965
  if (inst === null) return
958
- if (inst.order !== null) {
959
- for (let s of inst.streams) {
960
- if (s.dirty === null) continue
961
- s.dirty = null
962
- let bytes = inst.count * s.stride
963
- if (bytes === 0) continue
964
- let block = beginBufferWrite(s.buffer)
965
- new Uint8Array(block.buffer, 0, bytes).set(vertexBytes(s.data).subarray(0, bytes))
966
- endBufferWrite(s.buffer, bytes)
967
- }
968
- return
969
- }
966
+ let ordered = inst.order !== null
970
967
  for (let s of inst.streams) {
971
- if (s.dirty === null) continue
972
- let [lo, hi] = s.dirty
973
- s.dirty = null
974
- writeBuffer(s.buffer, vertexBytes(s.data).subarray(lo, hi), lo)
968
+ if (s.stream === null || !s.stream.dirty) continue
969
+ s.stream.publish(ordered, ordered ? inst.count : inst.capacity)
975
970
  }
976
971
  }
977
972
 
@@ -984,7 +979,7 @@ export function markLiveRecords(mesh: Mesh): void {
984
979
  // A transferred mesh has no mirror; the engine's own (buffer-lifetime)
985
980
  // mirror seeds a re-attached entry instead.
986
981
  if (inst === null || inst.count === 0 || inst.transfer) return
987
- for (let s of inst.streams) markRecords(mesh, s, 0, inst.count * s.stride)
982
+ for (let s of inst.streams) markRecords(mesh, s, 0, inst.count)
988
983
  }
989
984
 
990
985
  /**
@@ -1033,7 +1028,7 @@ export function addInstance(mesh: InstancedMesh, update?: TransformUpdate, paren
1033
1028
  // A recycled slot must not wear its last occupant's records.
1034
1029
  for (let s of li.streams) {
1035
1030
  vertexBytes(s.data).set(s.blank, slot * s.stride)
1036
- markRecords(l, s, slot * s.stride, (slot + 1) * s.stride)
1031
+ markRecords(l, s, slot, slot + 1)
1037
1032
  }
1038
1033
  }
1039
1034
  if (parent._scene) enterScene(instance, parent._scene)
@@ -1069,14 +1064,13 @@ export function reparentInstance(instance: InstanceNode, parent: SceneNode): voi
1069
1064
 
1070
1065
  // Grow the population's buffers to `next` records: replacements (never a
1071
1066
  // resize). An instanced mesh's live matrix records move to the new buffer
1072
- // in one core call and republish at the next flush; every stream's mirror
1073
- // is copied over and marked whole, so the scene republishes it; the entry
1074
- // re-points, then the old buffers, which the entry held alive until now,
1075
- // are freed.
1067
+ // in one core call and republish at the next flush; every stream grows
1068
+ // into a replacement with its mirror copied over and marked whole, so the
1069
+ // scene republishes it; the entry re-points, then the old buffers, which
1070
+ // the entry held alive until now, are freed.
1076
1071
  function growInstances(mesh: InstancedMesh | RecordMesh, next: number): void {
1077
1072
  let inst: MeshInstances = mesh._instances
1078
1073
  if (inst.transfer) throw new Error("growInstances: a transferred population is fixed at its creation records")
1079
- let previous = inst.capacity
1080
1074
  inst.capacity = next
1081
1075
  let freed: BufferId[] = []
1082
1076
  if (inst.matrix !== null) {
@@ -1085,15 +1079,13 @@ function growInstances(mesh: InstancedMesh | RecordMesh, next: number): void {
1085
1079
  // Out of a scene nothing is bound, and retargeting an empty source throws.
1086
1080
  if (inst.nodes !== null && inst.nodes.slots.some(n => n !== null && n._node !== null)) spatial.retargetRecords(freed[0]!, inst.matrix)
1087
1081
  }
1088
- inst.streams.forEach((s, i) => {
1089
- freed.push(s.buffer)
1090
- s.buffer = createBuffer(next * s.stride, { autoFree: false, label: streamLabel(inst.label, i) })
1091
- let held = vertexBytes(s.data)
1092
- viewStream(s, new ArrayBuffer(next * s.stride))
1093
- vertexBytes(s.data).set(held)
1094
- s.dirty = null
1095
- markRecords(mesh, s, 0, previous * s.stride)
1096
- })
1082
+ for (let s of inst.streams) {
1083
+ if (s.stream === null) throw new Error("growInstances: a stream without a mirror cannot grow")
1084
+ freed.push(s.stream.grow(next))
1085
+ s.buffer = s.stream.buffer
1086
+ viewStream(s, s.stream.bytes.buffer)
1087
+ mesh._scene?._setRecords(mesh)
1088
+ }
1097
1089
  mesh._scene?._setBuffer(mesh)
1098
1090
  for (let b of freed) destroyBuffer(b)
1099
1091
  if (inst.morph !== null) replacePopulationMorph(mesh, populationMorph(mesh.geometry, next, inst.label), true)
@@ -1180,7 +1172,7 @@ export function createRecordMesh(
1180
1172
  function copyRecords(mesh: RecordMesh, records: ArrayBufferView, written: number): void {
1181
1173
  let stream = mesh._instances.streams[0]!
1182
1174
  vertexBytes(stream.data).set(vertexBytes(records))
1183
- if (written > 0) markRecords(mesh, stream, 0, written * stream.stride)
1175
+ if (written > 0) markRecords(mesh, stream, 0, written)
1184
1176
  }
1185
1177
 
1186
1178
  /**
@@ -1220,7 +1212,7 @@ export function setRecordCount(mesh: RecordMesh, count: number): void {
1220
1212
  // old population's nearest records, a growth its stale tail. A
1221
1213
  // transferred mesh has no mirror to publish from; the engine follows
1222
1214
  // the entry's instance count into its own mirror's prefix instead.
1223
- if (inst.order !== null && !inst.transfer && n > 0) for (let s of inst.streams) markRecords(mesh, s, 0, n * s.stride)
1215
+ if (inst.order !== null && !inst.transfer && n > 0) for (let s of inst.streams) markRecords(mesh, s, 0, n)
1224
1216
  mesh._scene?._setCount(mesh)
1225
1217
  }
1226
1218
 
@@ -1259,7 +1251,10 @@ export function disposeInstances(mesh: InstancedMesh | RecordMesh): void {
1259
1251
  spatial.flush()
1260
1252
  }
1261
1253
  if (inst.matrix !== null) destroyBuffer(inst.matrix)
1262
- for (let s of inst.streams) destroyBuffer(s.buffer)
1254
+ for (let s of inst.streams) {
1255
+ if (s.stream !== null) s.stream.destroy()
1256
+ else destroyBuffer(s.buffer)
1257
+ }
1263
1258
  if (inst.morph !== null) destroyTexture(inst.morph.texture)
1264
1259
  for (let t of inst.textures) destroyTexture(t)
1265
1260
  ;(mesh as Mesh)._instances = null
package/src/scene.ts CHANGED
@@ -2071,7 +2071,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2071
2071
  for (let v of views) attachView(v, mesh)
2072
2072
  // Record writes made while the mesh was out of a scene kept their
2073
2073
  // dirty ranges: publish them now.
2074
- if (inst !== null && inst.streams.some(s => s.dirty !== null)) recordsDirty.add(mesh)
2074
+ if (inst !== null && inst.streams.some(s => s.stream !== null && s.stream.dirty)) recordsDirty.add(mesh)
2075
2075
  // Picking: an ordinary mesh's node carries its geometry's shape,
2076
2076
  // which is its box in the core index (following every
2077
2077
  // updateVertices) and its triangle narrowphase. An instanced mesh's