@solidrt/3d 0.0.55 → 0.0.56
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 +1083 -183
- package/README.md +15 -7
- package/examples/README.md +79 -5
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +1 -4
- package/examples/collision.tsx +250 -0
- package/examples/first-person.tsx +119 -0
- package/examples/fleet.tsx +194 -0
- package/examples/fog.tsx +2 -3
- package/examples/instanced.tsx +72 -32
- package/examples/lamps.tsx +92 -0
- package/examples/lit.tsx +3 -5
- package/examples/materials.tsx +150 -0
- package/examples/model-load.tsx +95 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +32 -6
- package/examples/pick.tsx +2 -4
- package/examples/probe.tsx +101 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +32 -3
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +86 -0
- package/examples/skybox.tsx +128 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +132 -0
- package/examples/sweep-paths.tsx +2 -4
- package/package.json +3 -3
- package/src/camera.ts +148 -0
- package/src/clip.ts +96 -0
- package/src/collision.ts +199 -0
- package/src/color.ts +32 -0
- package/src/components.tsx +763 -82
- package/src/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +306 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1343 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +57 -9
- package/src/light.ts +383 -0
- package/src/material.ts +667 -232
- package/src/math.ts +110 -0
- package/src/mesh.ts +728 -0
- package/src/mixer.ts +476 -0
- package/src/model-file.ts +80 -15
- package/src/model.ts +204 -35
- package/src/node.ts +506 -0
- package/src/orbit.ts +97 -48
- package/src/scene-pointer.ts +166 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1242 -1557
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
package/src/mesh.ts
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
// Meshes: the drawable nodes - plain, sprite and instanced - with their
|
|
2
|
+
// create-time state (geometry, material, per-mesh bindings) and the
|
|
3
|
+
// setter write paths that keep a live scene's draw entries in step. The
|
|
4
|
+
// scene side is reached through the node's SceneHooks (node.ts).
|
|
5
|
+
|
|
6
|
+
import { createBuffer, destroyBuffer, writeBuffer } from "@solidrt/core/gpu"
|
|
7
|
+
import type { BufferId, DrawId, InstanceAttribute, ShaderParams, TextureBindings, VertexAttribute } from "@solidrt/core/gpu"
|
|
8
|
+
import { geometryBounds, plane } from "./geometry.ts"
|
|
9
|
+
import type { Geometry } from "./geometry.ts"
|
|
10
|
+
import type { GeometryBuffers } from "./geometry-gpu.ts"
|
|
11
|
+
import type { Material } from "./material.ts"
|
|
12
|
+
import type { TransformUpdate, Vec3 } from "./math.ts"
|
|
13
|
+
import * as spatial from "flux:spatial"
|
|
14
|
+
import { enterScene, leaveScene, makeNode, remove, setTransform } from "./node.ts"
|
|
15
|
+
import type { SceneNode } from "./node.ts"
|
|
16
|
+
|
|
17
|
+
export type Mesh = SceneNode & {
|
|
18
|
+
kind: "mesh"
|
|
19
|
+
geometry: Geometry
|
|
20
|
+
material: Material
|
|
21
|
+
/** Explicit draw-order key (default 0), Three's name: lower draws first.
|
|
22
|
+
* Sorts within the opaque group and within the transparent group; the
|
|
23
|
+
* transparent group always follows the opaque one. Set with setRenderOrder. */
|
|
24
|
+
renderOrder: number
|
|
25
|
+
/** Draw into the scene's shadow map (default false, Three's default).
|
|
26
|
+
* Set with setCastShadow. A casting instanced mesh is skipped (the
|
|
27
|
+
* depth pass cannot know its record layout). */
|
|
28
|
+
castShadow: boolean
|
|
29
|
+
/** Layer membership bitmask (default 1, Three's `object.layers`): a
|
|
30
|
+
* target draws the mesh when its mask intersects this. Not inherited
|
|
31
|
+
* from ancestor Groups (Three's and Godot's rule). Set with setLayers. */
|
|
32
|
+
layers: number
|
|
33
|
+
/** Whether every target's frustum gates the mesh (default true, Three's
|
|
34
|
+
* `frustumCulled`): outside it the entry draws nothing. Off for geometry
|
|
35
|
+
* a vertex stage moves beyond its box (a fullscreen quad, a custom
|
|
36
|
+
* displacement). Set with setCulling. */
|
|
37
|
+
frustumCulled: boolean
|
|
38
|
+
/** World units the frustum test grows the box by on every side (default
|
|
39
|
+
* 0, Godot's `extra_cull_margin`): the pad for wind, wobble and any other
|
|
40
|
+
* vertex-stage displacement that stays bounded. Set with setCulling. */
|
|
41
|
+
cullMargin: number
|
|
42
|
+
/** Joint nodes whose world boxes, united, stand in for this mesh's own
|
|
43
|
+
* in the frustum test - a skinned part's cull box follows its pose.
|
|
44
|
+
* null for an unskinned mesh. */
|
|
45
|
+
_cullJoints: SceneNode[] | null
|
|
46
|
+
_entry: DrawId | null
|
|
47
|
+
/** The geometry-buffer reference the entry was built from, acquired at
|
|
48
|
+
* attach and what _detach releases - like _transparent, a snapshot,
|
|
49
|
+
* because setGeometry swaps mesh.geometry before the rebuild. */
|
|
50
|
+
_buffers: GeometryBuffers | null
|
|
51
|
+
/** material.transparent as of the last attach - the entry's actual
|
|
52
|
+
* pipeline state, and what _detach counts against (setMaterial swaps
|
|
53
|
+
* mesh.material before the rebuild). */
|
|
54
|
+
_transparent: boolean
|
|
55
|
+
/** World-space center of the local bounds, refreshed at sort time: the
|
|
56
|
+
* transparent sort key. */
|
|
57
|
+
_center: Vec3
|
|
58
|
+
_params: ShaderParams | null
|
|
59
|
+
/** Per-mesh sampler bindings merged over the material's at attach (a
|
|
60
|
+
* skin's uBones palette texture): create-time state, set before the
|
|
61
|
+
* mesh joins a scene, applied to entries drawn with the mesh's OWN
|
|
62
|
+
* material or a skinned stand-in (Material.skinned - a skinned shadow
|
|
63
|
+
* variant declares uBones) - any other override validates its bindings
|
|
64
|
+
* against a program that may not declare these names. */
|
|
65
|
+
_textures: TextureBindings | null
|
|
66
|
+
/** Instance state when the mesh was made by createInstancedMesh; null on
|
|
67
|
+
* an ordinary mesh. */
|
|
68
|
+
_instances: MeshInstances | null
|
|
69
|
+
/** True for a createSprite mesh: its quad faces the camera in the
|
|
70
|
+
* vertex stage, so it picks by a unit box instead of its flat triangles. */
|
|
71
|
+
_sprite: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The per-mesh half of instancing: the record buffer and its bookkeeping.
|
|
75
|
+
* Read the public fields freely; write through the population's own
|
|
76
|
+
* functions (addInstance/removeInstance, setRecords/setRecordCount) so
|
|
77
|
+
* the draw range follows. */
|
|
78
|
+
export type MeshInstances = {
|
|
79
|
+
/** The GPU record buffer (instance slot 0), owned by the mesh
|
|
80
|
+
* (disposeInstances frees it): the core-written matrices on an
|
|
81
|
+
* instanced mesh, the app's records on a record mesh. */
|
|
82
|
+
buffer: BufferId
|
|
83
|
+
/** Floats per record: INSTANCE_FLOATS on an instanced mesh, the
|
|
84
|
+
* material's slot-0 instanceAttributes summed on a record mesh. */
|
|
85
|
+
stride: number
|
|
86
|
+
/** Records the buffer has room for; doubles on growth (a replacement
|
|
87
|
+
* buffer, never a resize). */
|
|
88
|
+
capacity: number
|
|
89
|
+
/** The buffer label, carried to replacement buffers on growth. */
|
|
90
|
+
label: string | undefined
|
|
91
|
+
/** Records currently drawn (the entry's instanceCount while visible):
|
|
92
|
+
* the slot high-water mark on an instanced mesh, the records written
|
|
93
|
+
* on a record mesh. */
|
|
94
|
+
count: number
|
|
95
|
+
/** Explicit LOCAL bounds covering the whole population ([minX, minY,
|
|
96
|
+
* minZ, maxX, maxY, maxZ]) or null: the mesh node's own box, what the
|
|
97
|
+
* frustum test and the transparent sort use, and on a record mesh the
|
|
98
|
+
* only picking leaf (records are opaque data). An instanced mesh picks
|
|
99
|
+
* per instance regardless and, without them, culls by its instances'
|
|
100
|
+
* union (the scene's cull group over the live instance nodes). */
|
|
101
|
+
bounds: Float32Array | null
|
|
102
|
+
/** The node-backed population (createInstancedMesh): handles by slot
|
|
103
|
+
* (null = free) and the free list; null on a record mesh. */
|
|
104
|
+
nodes: InstanceSlots | null
|
|
105
|
+
/** The app-written style records (instance slot 1) of an instanced mesh
|
|
106
|
+
* whose material declares slot-1 attributes; null otherwise (and on a
|
|
107
|
+
* record mesh, whose one buffer is the app's already). */
|
|
108
|
+
style: InstanceStyle | null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type InstanceSlots = { slots: (InstanceNode | null)[]; free: number[] }
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The style half of an instanced mesh: one record per instance slot of
|
|
115
|
+
* the material's slot-1 instanceAttributes (a tint, a frame index, any
|
|
116
|
+
* per-copy floats the vertex stage reads), owned by the app and written
|
|
117
|
+
* per instance with setInstanceStyle - the JS twin of the core's matrix
|
|
118
|
+
* records in slot 0, @solidrt/2d's pose/style split one dimension up.
|
|
119
|
+
* Writes land in `data` and publish as ONE coalesced buffer write per
|
|
120
|
+
* mesh at the scene's next sync, whatever the number of instances
|
|
121
|
+
* styled; `buffer` grows with the matrix buffer.
|
|
122
|
+
*/
|
|
123
|
+
export type InstanceStyle = {
|
|
124
|
+
/** The GPU style buffer (instance slot 1), mesh-owned like `buffer`. */
|
|
125
|
+
buffer: BufferId
|
|
126
|
+
/** Floats per style record: the material's slot-1 attributes summed. */
|
|
127
|
+
stride: number
|
|
128
|
+
/** The JS mirror, capacity * stride floats; the buffer is published
|
|
129
|
+
* from it. Read freely, write through setInstanceStyle. */
|
|
130
|
+
data: Float32Array
|
|
131
|
+
/** The record a fresh (or recycled) slot starts with: the material's
|
|
132
|
+
* instanceStyle, zeros without one. */
|
|
133
|
+
blank: Float32Array
|
|
134
|
+
/** The float range [lo, hi) of `data` written since the last publish,
|
|
135
|
+
* or null. */
|
|
136
|
+
dirty: [number, number] | null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A mesh from createInstancedMesh: one draw entry drawing the geometry
|
|
140
|
+
* once per instance node (addInstance). */
|
|
141
|
+
export type InstancedMesh = Mesh & { _instances: MeshInstances & { nodes: InstanceSlots } }
|
|
142
|
+
|
|
143
|
+
/** A mesh from createRecordMesh: one draw entry drawing the geometry once
|
|
144
|
+
* per record of a JS-written buffer (setRecords). */
|
|
145
|
+
export type RecordMesh = Mesh & { _instances: MeshInstances & { nodes: null } }
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One instance of an InstancedMesh: a scene node like any other
|
|
149
|
+
* (setTransform, setTransition, setVisible, lookAt, worldPosition,
|
|
150
|
+
* pointer handlers, children of its own) whose placement inside the mesh
|
|
151
|
+
* the core writes into the mesh's record buffer at `_slot` - one
|
|
152
|
+
* coalesced buffer write per flush however many instances moved. A
|
|
153
|
+
* hidden instance draws nothing (its record collapses to zero scale).
|
|
154
|
+
* Slot-bound to its mesh: created by addInstance, destroyed by
|
|
155
|
+
* removeInstance; the generic add/remove reject it.
|
|
156
|
+
*/
|
|
157
|
+
export type InstanceNode = SceneNode & {
|
|
158
|
+
kind: "instance"
|
|
159
|
+
/** The owning mesh; null once removed (the handle is inert). */
|
|
160
|
+
mesh: InstancedMesh | null
|
|
161
|
+
/** The record slot, fixed for the instance's life. */
|
|
162
|
+
readonly _slot: number
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Layer masks are 32-bit sets, Three's Object3D.layers width.
|
|
166
|
+
export function checkMask(mask: number, site: string): number {
|
|
167
|
+
if (!Number.isInteger(mask) || mask < 0 || mask > 0xffffffff) {
|
|
168
|
+
throw new Error(site + ": layers must be an integer bitmask in 0..0xffffffff, got " + mask)
|
|
169
|
+
}
|
|
170
|
+
return mask
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createMesh(geometry: Geometry, material: Material): Mesh {
|
|
174
|
+
let mesh = makeNode("mesh") as Mesh
|
|
175
|
+
mesh.geometry = geometry
|
|
176
|
+
mesh.material = material
|
|
177
|
+
mesh.renderOrder = 0
|
|
178
|
+
mesh.castShadow = false
|
|
179
|
+
mesh.layers = 1
|
|
180
|
+
mesh.frustumCulled = true
|
|
181
|
+
mesh.cullMargin = 0
|
|
182
|
+
mesh._cullJoints = null
|
|
183
|
+
mesh._entry = null
|
|
184
|
+
mesh._buffers = null
|
|
185
|
+
mesh._transparent = false
|
|
186
|
+
mesh._center = [0, 0, 0]
|
|
187
|
+
mesh._params = null
|
|
188
|
+
mesh._textures = null
|
|
189
|
+
mesh._instances = null
|
|
190
|
+
mesh._sprite = false
|
|
191
|
+
return mesh
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Every sprite draws the same unit quad, built once: geometry is data
|
|
195
|
+
// and its GPU buffers are acquired per mesh, so one shared value is the
|
|
196
|
+
// normal sharing story. The box is the quad's reach at any facing: the
|
|
197
|
+
// unit quad's corners lie on a sphere of radius sqrt(0.5), so the box of
|
|
198
|
+
// that sphere holds it however the camera turns it - what the frustum
|
|
199
|
+
// test needs, and close enough for a pick.
|
|
200
|
+
let spriteQuad: Geometry | undefined
|
|
201
|
+
const SPRITE_REACH = Math.SQRT1_2
|
|
202
|
+
const SPRITE_BOUNDS = new Float32Array([-SPRITE_REACH, -SPRITE_REACH, -SPRITE_REACH, SPRITE_REACH, SPRITE_REACH, SPRITE_REACH])
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A camera-facing quad, Three's `Sprite`: a unit plane drawn with a
|
|
206
|
+
* `sprite()` material (any material works, but only a sprite material
|
|
207
|
+
* turns the quad; there is no `geometry` argument). Size it with `scale` -
|
|
208
|
+
* a scale of [2, 1, 1] is a 2 x 1 world-unit quad - and place it like any
|
|
209
|
+
* mesh; its rotation is ignored, the camera decides the facing. Picking
|
|
210
|
+
* is by a unit box around the center (the quad's reach at any facing, an
|
|
211
|
+
* approximation), so hits carry no normal/face/uv.
|
|
212
|
+
*/
|
|
213
|
+
export function createSprite(material: Material): Mesh {
|
|
214
|
+
if (spriteQuad === undefined) spriteQuad = plane({ label: "sprite" })
|
|
215
|
+
let mesh = createMesh(spriteQuad, material)
|
|
216
|
+
mesh._sprite = true
|
|
217
|
+
return mesh
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The local box picking and sorting work from: explicit instance bounds
|
|
221
|
+
* when the mesh is instanced (null without them - no leaf, no hits), the
|
|
222
|
+
* unit box for a sprite, the geometry's own bounds otherwise. */
|
|
223
|
+
export function localBounds(mesh: Mesh): Float32Array | null {
|
|
224
|
+
if (mesh._instances !== null) return mesh._instances.bounds
|
|
225
|
+
return mesh._sprite ? SPRITE_BOUNDS : geometryBounds(mesh.geometry)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
|
|
229
|
+
|
|
230
|
+
/** Floats per record of one instance slot of an attribute list (slot 0
|
|
231
|
+
* by default; the list's `slot` keys pick the others). */
|
|
232
|
+
export function instanceStride(attributes: InstanceAttribute[], slot = 0): number {
|
|
233
|
+
let stride = 0
|
|
234
|
+
for (let a of attributes) if ((a.slot ?? 0) === slot) stride += ATTRIBUTE_FLOATS[a.format]
|
|
235
|
+
return stride
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The highest instance slot an attribute list names.
|
|
239
|
+
function instanceSlots(attributes: InstanceAttribute[]): number {
|
|
240
|
+
let top = 0
|
|
241
|
+
for (let a of attributes) top = Math.max(top, a.slot ?? 0)
|
|
242
|
+
return top + 1
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Floats per instance record on an instanced mesh: one column-major
|
|
246
|
+
* mat4, the instance's placement inside the mesh (the material reads it
|
|
247
|
+
* through INSTANCE_MATRIX_ATTRIBUTES / INSTANCE_MATRIX in ./glsl). */
|
|
248
|
+
export const INSTANCE_FLOATS = 16
|
|
249
|
+
// Instance slots an instanced mesh reserves without a capacity; growth
|
|
250
|
+
// doubles past it.
|
|
251
|
+
const DEFAULT_CAPACITY = 64
|
|
252
|
+
// The instance buffers a mesh binds: the matrix slot and the style slot.
|
|
253
|
+
const MESH_INSTANCE_SLOTS = 2
|
|
254
|
+
|
|
255
|
+
/** What both population kinds take. */
|
|
256
|
+
type PopulationOptions = {
|
|
257
|
+
/** LOCAL bounds covering the population ([minX, minY, minZ, maxX, maxY,
|
|
258
|
+
* maxZ] - geometryBounds' shape), copied in: the mesh node's own box,
|
|
259
|
+
* what the frustum test and the transparent sort use. Absent, an
|
|
260
|
+
* instanced mesh culls by the union of its live instances' boxes (the
|
|
261
|
+
* core follows them) and sorts by its node position; a record mesh is
|
|
262
|
+
* never culled. */
|
|
263
|
+
bounds?: ArrayLike<number>
|
|
264
|
+
/** Debug label for the record buffer. */
|
|
265
|
+
label?: string
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export type InstancedMeshOptions = PopulationOptions & {
|
|
269
|
+
/** Instance slots reserved up front (default 64). addInstance past it
|
|
270
|
+
* doubles the buffer: a replacement whose live records the core moves
|
|
271
|
+
* in one call and republishes at the next flush - amortized like a
|
|
272
|
+
* dynamic array; reserve realistically to skip the copies. */
|
|
273
|
+
capacity?: number
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A record mesh's bounds are also its ONLY picking leaf: records are
|
|
277
|
+
* opaque data, so without them the mesh never picks and pointer events
|
|
278
|
+
* never target it. */
|
|
279
|
+
export type RecordMeshOptions = PopulationOptions
|
|
280
|
+
|
|
281
|
+
function copyBounds(bounds: ArrayLike<number> | undefined, site: string): Float32Array | null {
|
|
282
|
+
if (bounds === undefined) return null
|
|
283
|
+
if (bounds.length !== 6) throw new Error(site + ": bounds must be [minX, minY, minZ, maxX, maxY, maxZ]")
|
|
284
|
+
let out = new Float32Array(6)
|
|
285
|
+
for (let i = 0; i < 6; i++) out[i] = bounds[i]!
|
|
286
|
+
return out
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function instancedAttributes(material: Material, site: string): InstanceAttribute[] {
|
|
290
|
+
let attributes = material.instanceAttributes
|
|
291
|
+
if (attributes === undefined) {
|
|
292
|
+
throw new Error(site + ": the material declares no instanceAttributes - build it with shaderMaterialClass({ instanceAttributes: [...] }) or a stock material's `instanced`")
|
|
293
|
+
}
|
|
294
|
+
return attributes
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Whether a material's instance attributes fit a mesh's population: the
|
|
299
|
+
* slot-0 stride must be the mesh's record stride, a slot-1 layout must
|
|
300
|
+
* match the mesh's style stride (a record mesh has no slot 1), and no
|
|
301
|
+
* slot beyond. Throws otherwise - at creation for the mesh's own
|
|
302
|
+
* material, at add() for a swapped one, at a shadow view's attach for a
|
|
303
|
+
* shadow variant.
|
|
304
|
+
*/
|
|
305
|
+
export function checkInstancePairing(material: Material, inst: MeshInstances, site: string): void {
|
|
306
|
+
let attrs = instancedAttributes(material, site)
|
|
307
|
+
let slots = instanceSlots(attrs)
|
|
308
|
+
if (slots > MESH_INSTANCE_SLOTS) {
|
|
309
|
+
throw new Error(site + ": instance attributes name slot " + (slots - 1) + "; a mesh binds slots 0 (records) and 1 (style) only")
|
|
310
|
+
}
|
|
311
|
+
let stride = instanceStride(attrs, 0)
|
|
312
|
+
if (stride !== inst.stride) {
|
|
313
|
+
throw new Error(site + ": the material's slot-0 instance attributes take " + stride + " floats but the mesh's records are " + inst.stride)
|
|
314
|
+
}
|
|
315
|
+
let styleStride = instanceStride(attrs, 1)
|
|
316
|
+
if (styleStride > 0) {
|
|
317
|
+
if (inst.style === null) {
|
|
318
|
+
throw new Error(site + ": the material declares slot-1 (style) instance attributes but the mesh has no style records" + (inst.nodes === null ? " (a record mesh is slot 0 only)" : ""))
|
|
319
|
+
}
|
|
320
|
+
if (styleStride !== inst.style.stride) {
|
|
321
|
+
throw new Error(site + ": the material's slot-1 (style) attributes take " + styleStride + " floats but the mesh's style records are " + inst.style.stride)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The instance-buffer binding of a draw entry for a material over a
|
|
327
|
+
* population: the plain slot-0 key, or both slots when the material's
|
|
328
|
+
* pipeline reads the style records too (the two spellings are exclusive
|
|
329
|
+
* and must match the pipeline's slot count exactly). */
|
|
330
|
+
export function instanceBinding(material: Material, inst: MeshInstances): { instanceBuffer?: BufferId; instanceBuffers?: BufferId[] } {
|
|
331
|
+
let attrs = material.instanceAttributes
|
|
332
|
+
if (attrs !== undefined && inst.style !== null && instanceSlots(attrs) > 1) return { instanceBuffers: [inst.buffer, inst.style.buffer] }
|
|
333
|
+
return { instanceBuffer: inst.buffer }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A mesh drawing `geometry` once per instance NODE: one draw entry, one
|
|
338
|
+
* uModel placing the whole population, and N instances (addInstance)
|
|
339
|
+
* that are scene nodes of their own, whose placement inside the mesh the
|
|
340
|
+
* core writes into the record buffer - so native transitions, clip
|
|
341
|
+
* players and picking reach every instance, and JS writes nothing per
|
|
342
|
+
* frame. Three's InstancedMesh count constructor over Unity's
|
|
343
|
+
* one-transform-per-instance model; for records only JS can compute at
|
|
344
|
+
* scale, createRecordMesh is the raw form. The material must declare the
|
|
345
|
+
* instance-matrix attributes in slot 0 (INSTANCE_MATRIX_ATTRIBUTES, read
|
|
346
|
+
* through INSTANCE_MATRIX in ./glsl as `uModel * instanceMatrix()` - a
|
|
347
|
+
* stock material's `instanced` does both); anything else throws here.
|
|
348
|
+
* Slot-1 attributes, when the material declares any (a stock material's
|
|
349
|
+
* `instanceColors`, a custom class's own layout), give every instance a
|
|
350
|
+
* STYLE record beside its matrix: app-owned floats written with
|
|
351
|
+
* setInstanceStyle, starting from the material's instanceStyle.
|
|
352
|
+
*
|
|
353
|
+
* The result is an ordinary Mesh: add/remove, setTransform (the whole
|
|
354
|
+
* population), setVisible (hiding zeroes the drawn count, unhiding
|
|
355
|
+
* restores it), setMeshParams and renderOrder all apply; disposeInstances
|
|
356
|
+
* frees the record buffers when done for good.
|
|
357
|
+
*/
|
|
358
|
+
export function createInstancedMesh(geometry: Geometry, material: Material, opts?: InstancedMeshOptions): InstancedMesh {
|
|
359
|
+
let attrs = instancedAttributes(material, "createInstancedMesh")
|
|
360
|
+
let stride = instanceStride(attrs, 0)
|
|
361
|
+
if (stride !== INSTANCE_FLOATS) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
"createInstancedMesh: the material's slot-0 instanceAttributes take " + stride + " floats per record; an instance record is the " + INSTANCE_FLOATS + "-float matrix of INSTANCE_MATRIX_ATTRIBUTES",
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
let capacity = opts?.capacity ?? DEFAULT_CAPACITY
|
|
367
|
+
if (!(Number.isInteger(capacity) && capacity > 0)) {
|
|
368
|
+
throw new Error("createInstancedMesh: capacity must be a positive integer, got " + capacity)
|
|
369
|
+
}
|
|
370
|
+
let styleStride = instanceStride(attrs, 1)
|
|
371
|
+
let style: InstanceStyle | null = null
|
|
372
|
+
if (styleStride > 0) {
|
|
373
|
+
let blank = new Float32Array(styleStride)
|
|
374
|
+
if (material.instanceStyle !== undefined) {
|
|
375
|
+
if (material.instanceStyle.length !== styleStride) {
|
|
376
|
+
throw new Error("createInstancedMesh: the material's instanceStyle has " + material.instanceStyle.length + " floats but its slot-1 attributes take " + styleStride)
|
|
377
|
+
}
|
|
378
|
+
blank.set(material.instanceStyle)
|
|
379
|
+
}
|
|
380
|
+
style = {
|
|
381
|
+
buffer: createBuffer(capacity * styleStride * 4, { autoFree: false, label: opts?.label === undefined ? undefined : opts.label + "-style" }),
|
|
382
|
+
stride: styleStride,
|
|
383
|
+
data: new Float32Array(capacity * styleStride),
|
|
384
|
+
blank,
|
|
385
|
+
dirty: null,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
let mesh = createMesh(geometry, material) as InstancedMesh
|
|
389
|
+
mesh._instances = {
|
|
390
|
+
buffer: createBuffer(capacity * INSTANCE_FLOATS * 4, { autoFree: false, label: opts?.label }),
|
|
391
|
+
stride: INSTANCE_FLOATS,
|
|
392
|
+
capacity,
|
|
393
|
+
label: opts?.label,
|
|
394
|
+
count: 0,
|
|
395
|
+
bounds: copyBounds(opts?.bounds, "createInstancedMesh"),
|
|
396
|
+
nodes: { slots: [], free: [] },
|
|
397
|
+
style,
|
|
398
|
+
}
|
|
399
|
+
checkInstancePairing(material, mesh._instances, "createInstancedMesh")
|
|
400
|
+
return mesh
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Extend a style's dirty range over [lo, hi) floats of the mirror and
|
|
404
|
+
// ask the scene to publish it at the next sync.
|
|
405
|
+
function markStyle(mesh: InstancedMesh, style: InstanceStyle, lo: number, hi: number): void {
|
|
406
|
+
if (style.dirty === null) style.dirty = [lo, hi]
|
|
407
|
+
else {
|
|
408
|
+
if (lo < style.dirty[0]) style.dirty[0] = lo
|
|
409
|
+
if (hi > style.dirty[1]) style.dirty[1] = hi
|
|
410
|
+
}
|
|
411
|
+
mesh._scene?._setStyle(mesh)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Write an instance's style record - the floats of the material's
|
|
416
|
+
* slot-1 instance attributes, in order (`[r, g, b, a]` for a stock
|
|
417
|
+
* material's instanceColors): the app's per-copy data beside the matrix
|
|
418
|
+
* the core writes. Any number of instances styled between two frames
|
|
419
|
+
* cost one coalesced buffer write at the scene's sync, so this is a
|
|
420
|
+
* frame-rate path like setTransform. Throws on a material without
|
|
421
|
+
* slot-1 attributes or a record of the wrong length; a no-op on a
|
|
422
|
+
* removed instance.
|
|
423
|
+
*/
|
|
424
|
+
export function setInstanceStyle(instance: InstanceNode, values: ArrayLike<number>): void {
|
|
425
|
+
let mesh = instance.mesh
|
|
426
|
+
if (mesh === null) return
|
|
427
|
+
let style = mesh._instances.style
|
|
428
|
+
if (style === null) throw new Error("setInstanceStyle: the mesh's material declares no slot-1 (style) instance attributes")
|
|
429
|
+
if (values.length !== style.stride) {
|
|
430
|
+
throw new Error("setInstanceStyle: a style record is " + style.stride + " floats, got " + values.length)
|
|
431
|
+
}
|
|
432
|
+
let at = instance._slot * style.stride
|
|
433
|
+
style.data.set(values, at)
|
|
434
|
+
markStyle(mesh, style, at, at + style.stride)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Publish an instanced mesh's pending style writes as one buffer write
|
|
438
|
+
* (the scene calls it from its sync; nothing to do without a dirty
|
|
439
|
+
* range). */
|
|
440
|
+
export function publishInstanceStyle(mesh: InstancedMesh): void {
|
|
441
|
+
let style = mesh._instances.style
|
|
442
|
+
if (style === null || style.dirty === null) return
|
|
443
|
+
let [lo, hi] = style.dirty
|
|
444
|
+
style.dirty = null
|
|
445
|
+
writeBuffer(style.buffer, style.data.subarray(lo, hi), lo * 4)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Add an instance to an instanced mesh: a scene node placed by `update`
|
|
450
|
+
* (position/rotation/quaternion/scale; absent keys at their identity)
|
|
451
|
+
* under `parent` - the mesh itself by default, or a node inside the
|
|
452
|
+
* mesh's subtree (a squad group within a fleet: the record stays
|
|
453
|
+
* mesh-relative through it). Its record slot is fixed for its life (a
|
|
454
|
+
* removed instance's slot recycles to the next add); past the
|
|
455
|
+
* reservation the buffer doubles. From here on it is a node like any
|
|
456
|
+
* other: setTransform/setTransition/setVisible, lookAt, worldPosition,
|
|
457
|
+
* pointer handlers, children of its own (a headlight mesh under a car
|
|
458
|
+
* instance) - and removeInstance, never remove.
|
|
459
|
+
*/
|
|
460
|
+
export function addInstance(mesh: InstancedMesh, update?: TransformUpdate, parent: SceneNode = mesh): InstanceNode {
|
|
461
|
+
let inst: (MeshInstances & { nodes: InstanceSlots }) | null = mesh._instances
|
|
462
|
+
if (inst === null) throw new Error("addInstance: the mesh's instances are disposed")
|
|
463
|
+
for (let p: SceneNode | null = parent; p !== mesh; p = p.parent) {
|
|
464
|
+
if (p === null) throw new Error("addInstance: parent must be the mesh or a node inside its subtree")
|
|
465
|
+
}
|
|
466
|
+
let slot = inst.nodes.free.pop() ?? inst.nodes.slots.length
|
|
467
|
+
if (slot >= inst.capacity) growInstances(mesh, inst.capacity * 2)
|
|
468
|
+
let instance = makeNode("instance") as InstanceNode
|
|
469
|
+
instance.mesh = mesh
|
|
470
|
+
;(instance as { _slot: number })._slot = slot
|
|
471
|
+
if (update !== undefined) setTransform(instance, update)
|
|
472
|
+
inst.nodes.slots[slot] = instance
|
|
473
|
+
instance.parent = parent
|
|
474
|
+
parent.children.push(instance)
|
|
475
|
+
if (slot >= inst.count) {
|
|
476
|
+
inst.count = slot + 1
|
|
477
|
+
mesh._scene?._setCount(mesh)
|
|
478
|
+
}
|
|
479
|
+
// A recycled slot must not wear its last occupant's style.
|
|
480
|
+
let style = inst.style
|
|
481
|
+
if (style !== null) {
|
|
482
|
+
let at = slot * style.stride
|
|
483
|
+
style.data.set(style.blank, at)
|
|
484
|
+
markStyle(mesh, style, at, at + style.stride)
|
|
485
|
+
}
|
|
486
|
+
if (parent._scene) enterScene(instance, parent._scene)
|
|
487
|
+
return instance
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Double the record buffers: replacements (never a resize) the live
|
|
491
|
+
// instances' record sinks move to in one core call, republished whole at
|
|
492
|
+
// the next flush, and the style mirror re-published whole from JS; the
|
|
493
|
+
// entry re-points, then the old buffers, which the entry held alive until
|
|
494
|
+
// now, are freed.
|
|
495
|
+
function growInstances(mesh: InstancedMesh, next: number): void {
|
|
496
|
+
let inst = mesh._instances
|
|
497
|
+
let previous = inst.buffer
|
|
498
|
+
inst.buffer = createBuffer(next * INSTANCE_FLOATS * 4, { autoFree: false, label: inst.label })
|
|
499
|
+
inst.capacity = next
|
|
500
|
+
// Out of a scene nothing is bound, and retargeting an empty source throws.
|
|
501
|
+
if (inst.nodes.slots.some(n => n !== null && n._node !== null)) spatial.retargetRecords(previous, inst.buffer)
|
|
502
|
+
let style = inst.style
|
|
503
|
+
let previousStyle: BufferId | null = null
|
|
504
|
+
if (style !== null) {
|
|
505
|
+
previousStyle = style.buffer
|
|
506
|
+
style.buffer = createBuffer(next * style.stride * 4, { autoFree: false, label: inst.label === undefined ? undefined : inst.label + "-style" })
|
|
507
|
+
let grown = new Float32Array(next * style.stride)
|
|
508
|
+
grown.set(style.data)
|
|
509
|
+
style.data = grown
|
|
510
|
+
style.dirty = null
|
|
511
|
+
markStyle(mesh, style, 0, inst.count * style.stride)
|
|
512
|
+
}
|
|
513
|
+
mesh._scene?._setBuffer(mesh)
|
|
514
|
+
destroyBuffer(previous)
|
|
515
|
+
if (previousStyle !== null) destroyBuffer(previousStyle)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Destroy an instance: its slot hides at the next flush and recycles to
|
|
520
|
+
* the next addInstance, and the handle goes inert (`mesh` null; later
|
|
521
|
+
* writes are no-ops). Its children leave the scene with it and stay
|
|
522
|
+
* parented to the dead node - remove() one to re-add it elsewhere. Remove
|
|
523
|
+
* means destroy here, as for a 2d sprite: an instance cannot exist
|
|
524
|
+
* outside its mesh.
|
|
525
|
+
*/
|
|
526
|
+
export function removeInstance(instance: InstanceNode): void {
|
|
527
|
+
let mesh = instance.mesh
|
|
528
|
+
if (mesh === null) return
|
|
529
|
+
if (instance._scene) leaveScene(instance)
|
|
530
|
+
let parent = instance.parent
|
|
531
|
+
if (parent !== null) {
|
|
532
|
+
let i = parent.children.indexOf(instance)
|
|
533
|
+
if (i >= 0) parent.children.splice(i, 1)
|
|
534
|
+
instance.parent = null
|
|
535
|
+
}
|
|
536
|
+
let nodes = mesh._instances.nodes
|
|
537
|
+
nodes.slots[instance._slot] = null
|
|
538
|
+
nodes.free.push(instance._slot)
|
|
539
|
+
instance.mesh = null
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* A mesh drawing `geometry` once per record of `records`: one draw entry,
|
|
544
|
+
* one uModel write, N instances whose per-copy data is a few JS-written
|
|
545
|
+
* floats - the raw escape hatch for motion only JS can compute at scale
|
|
546
|
+
* (a particle sim, a crowd stepped in a worker), Three's InstancedMesh
|
|
547
|
+
* and Godot's MultiMesh as plain data. The material must declare
|
|
548
|
+
* `instanceAttributes` (shaderMaterialClass); its vertex stage reads each
|
|
549
|
+
* record through those `in` variables. `records` is the interleaved
|
|
550
|
+
* attribute data (stride = the attributes' floats summed) and is uploaded
|
|
551
|
+
* here; its length is the buffer's initial capacity, which setRecords
|
|
552
|
+
* grows past on demand. `count` limits how many records draw (default
|
|
553
|
+
* all), up to capacity. Everything mesh applies as on an instanced mesh;
|
|
554
|
+
* the population is written with setRecords, dialed with setRecordCount,
|
|
555
|
+
* and its buffer freed with disposeInstances.
|
|
556
|
+
*/
|
|
557
|
+
export function createRecordMesh(
|
|
558
|
+
geometry: Geometry,
|
|
559
|
+
material: Material,
|
|
560
|
+
records: Float32Array,
|
|
561
|
+
count?: number,
|
|
562
|
+
opts?: RecordMeshOptions,
|
|
563
|
+
): RecordMesh {
|
|
564
|
+
let attrs = instancedAttributes(material, "createRecordMesh")
|
|
565
|
+
let stride = instanceStride(attrs, 0)
|
|
566
|
+
if (records.length % stride !== 0) {
|
|
567
|
+
throw new Error("createRecordMesh: " + records.length + " floats is not a whole number of " + stride + "-float records")
|
|
568
|
+
}
|
|
569
|
+
let capacity = records.length / stride
|
|
570
|
+
let mesh = createMesh(geometry, material) as RecordMesh
|
|
571
|
+
mesh._instances = {
|
|
572
|
+
buffer: createBuffer(records, { autoFree: false, label: opts?.label }),
|
|
573
|
+
stride,
|
|
574
|
+
capacity,
|
|
575
|
+
label: opts?.label,
|
|
576
|
+
count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
|
|
577
|
+
bounds: copyBounds(opts?.bounds, "createRecordMesh"),
|
|
578
|
+
nodes: null,
|
|
579
|
+
style: null,
|
|
580
|
+
}
|
|
581
|
+
checkInstancePairing(material, mesh._instances, "createRecordMesh")
|
|
582
|
+
return mesh
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Overwrite a record mesh's records from the start of its buffer and (by
|
|
587
|
+
* default) draw exactly the records written - pass `count` to draw fewer,
|
|
588
|
+
* or to keep more previously written ones alive past a partial rewrite.
|
|
589
|
+
* More records than the buffer holds grow it: capacity doubles (or jumps
|
|
590
|
+
* to the records written when that is more), a new buffer is created and
|
|
591
|
+
* written, the mesh's entry is re-pointed at it and the old buffer freed
|
|
592
|
+
* - so a population grows without a new mesh, with the copies amortized
|
|
593
|
+
* like any dynamic array (size the initial records to skip them).
|
|
594
|
+
* Frame-rate-safe like setMeshParams when no growth happens.
|
|
595
|
+
*/
|
|
596
|
+
export function setRecords(mesh: RecordMesh, records: Float32Array, count?: number): void {
|
|
597
|
+
let inst = mesh._instances
|
|
598
|
+
if (records.length % inst.stride !== 0) {
|
|
599
|
+
throw new Error("setRecords: " + records.length + " floats is not a whole number of " + inst.stride + "-float records")
|
|
600
|
+
}
|
|
601
|
+
let written = records.length / inst.stride
|
|
602
|
+
if (written > inst.capacity) {
|
|
603
|
+
let previous = inst.buffer
|
|
604
|
+
inst.capacity = Math.max(written, inst.capacity * 2)
|
|
605
|
+
inst.buffer = createBuffer(inst.capacity * inst.stride * 4, { autoFree: false, label: inst.label })
|
|
606
|
+
mesh._scene?._setBuffer(mesh)
|
|
607
|
+
destroyBuffer(previous)
|
|
608
|
+
}
|
|
609
|
+
writeBuffer(inst.buffer, records)
|
|
610
|
+
setRecordCount(mesh, count ?? written)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Set how many records draw (clamped to [0, capacity]). The visibility
|
|
614
|
+
* switch composes: a hidden mesh stores the count and draws it on unhide. */
|
|
615
|
+
export function setRecordCount(mesh: RecordMesh, count: number): void {
|
|
616
|
+
let inst = mesh._instances
|
|
617
|
+
let n = Math.max(0, Math.min(Math.floor(count), inst.capacity))
|
|
618
|
+
if (n === inst.count) return
|
|
619
|
+
inst.count = n
|
|
620
|
+
mesh._scene?._setCount(mesh)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Detach the mesh (if attached) and free its record buffers (the style
|
|
625
|
+
* buffer with the matrix one). The buffers are mesh-owned with no
|
|
626
|
+
* reference count (unlike geometry buffers they are never shared), so
|
|
627
|
+
* this is the one explicit free; the mesh cannot be re-added afterwards.
|
|
628
|
+
* An instanced mesh's instances go inert with it.
|
|
629
|
+
*/
|
|
630
|
+
export function disposeInstances(mesh: InstancedMesh | RecordMesh): void {
|
|
631
|
+
let inst: MeshInstances | null = mesh._instances
|
|
632
|
+
if (inst === null) return
|
|
633
|
+
if (mesh._scene) remove(mesh)
|
|
634
|
+
if (inst.nodes !== null) {
|
|
635
|
+
for (let n of inst.nodes.slots) if (n !== null) n.mesh = null
|
|
636
|
+
// The destroyed instance nodes' hiding writes land now, while the
|
|
637
|
+
// buffer still exists (a write into a freed buffer warns).
|
|
638
|
+
spatial.flush()
|
|
639
|
+
}
|
|
640
|
+
destroyBuffer(inst.buffer)
|
|
641
|
+
if (inst.style !== null) destroyBuffer(inst.style.buffer)
|
|
642
|
+
;(mesh as Mesh)._instances = null
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Set a mesh's explicit draw-order key (see Mesh.renderOrder). */
|
|
646
|
+
export function setRenderOrder(mesh: Mesh, order: number): void {
|
|
647
|
+
if (mesh.renderOrder === order) return
|
|
648
|
+
mesh.renderOrder = order
|
|
649
|
+
mesh._scene?._reorder()
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Set a mesh's layer membership (bitmask, default 1). A target draws the
|
|
654
|
+
* mesh when its mask intersects this: the scene's own mask (`layers` on
|
|
655
|
+
* createScene, `scene.setLayers`), each view's (`layers` on createView,
|
|
656
|
+
* `view.setLayers`); shadow views follow the scene's. A mesh masked out of
|
|
657
|
+
* the scene is also skipped by pick()/raycast(), like an invisible one -
|
|
658
|
+
* unless a raycast passes its own mask (QueryOptions.layers), which is
|
|
659
|
+
* how an undrawn collision-only mesh stays queryable. `layers: 0` draws
|
|
660
|
+
* nowhere. Not inherited from ancestor Groups.
|
|
661
|
+
*/
|
|
662
|
+
export function setLayers(mesh: Mesh, layers: number): void {
|
|
663
|
+
checkMask(layers, "setLayers")
|
|
664
|
+
if (mesh.layers === layers) return
|
|
665
|
+
mesh.layers = layers
|
|
666
|
+
mesh._scene?._setLayers(mesh)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Draw the mesh into the scene's shadow map, or stop (see Mesh.castShadow). */
|
|
670
|
+
export function setCastShadow(mesh: Mesh, cast: boolean): void {
|
|
671
|
+
if (mesh.castShadow === cast) return
|
|
672
|
+
mesh.castShadow = cast
|
|
673
|
+
mesh._scene?._setCast(mesh)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Frustum culling per mesh: `frustumCulled` (default true) switches the
|
|
677
|
+
* gate, `cullMargin` (world units, default 0) grows the tested box. */
|
|
678
|
+
export function setCulling(mesh: Mesh, options: { frustumCulled?: boolean; cullMargin?: number }): void {
|
|
679
|
+
let culled = options.frustumCulled ?? mesh.frustumCulled
|
|
680
|
+
let margin = options.cullMargin ?? mesh.cullMargin
|
|
681
|
+
if (!(margin >= 0)) throw new Error("setCulling: cullMargin must be >= 0")
|
|
682
|
+
if (culled === mesh.frustumCulled && margin === mesh.cullMargin) return
|
|
683
|
+
mesh.frustumCulled = culled
|
|
684
|
+
mesh.cullMargin = margin
|
|
685
|
+
if (mesh._node !== null) {
|
|
686
|
+
spatial.setCull(mesh._node, culled, margin)
|
|
687
|
+
mesh._scene?._schedule()
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Swap a mesh's geometry: its draw entry is rebuilt (the scene re-sorts
|
|
692
|
+
* the list, so the mesh keeps its place). */
|
|
693
|
+
export function setGeometry(mesh: Mesh, geometry: Geometry): void {
|
|
694
|
+
if (mesh._sprite) throw new Error("setGeometry: a sprite draws the shared unit quad and takes no geometry")
|
|
695
|
+
if (mesh.geometry === geometry) return
|
|
696
|
+
mesh.geometry = geometry
|
|
697
|
+
rebuildEntry(mesh)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Swap a mesh's material: its draw entry is rebuilt. */
|
|
701
|
+
export function setMaterial(mesh: Mesh, material: Material): void {
|
|
702
|
+
if (mesh.material === material) return
|
|
703
|
+
mesh.material = material
|
|
704
|
+
rebuildEntry(mesh)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function rebuildEntry(mesh: Mesh): void {
|
|
708
|
+
let scene = mesh._scene
|
|
709
|
+
if (scene) {
|
|
710
|
+
scene._detach(mesh)
|
|
711
|
+
scene._attach(mesh)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Write per-mesh uniforms - the channel for a custom material's app-driven
|
|
717
|
+
* values (a time, a per-object tint). Names must be
|
|
718
|
+
* declared and used by the mesh's material shaders (unknown names throw at
|
|
719
|
+
* the call site, the engine's validation contract). Values persist on the
|
|
720
|
+
* mesh: they survive geometry/material entry rebuilds and re-apply then.
|
|
721
|
+
* Also the frame-rate path - like setTransform, call it from onFrame
|
|
722
|
+
* freely.
|
|
723
|
+
*/
|
|
724
|
+
export function setMeshParams(mesh: Mesh, params: ShaderParams): void {
|
|
725
|
+
if (mesh._params === null) mesh._params = {}
|
|
726
|
+
Object.assign(mesh._params, params)
|
|
727
|
+
mesh._scene?._setParams(mesh, params)
|
|
728
|
+
}
|