lecodes-sdk 2.3.1 → 2.4.0

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/package.json CHANGED
@@ -43,7 +43,7 @@
43
43
  "marcidb-embedded": "^0.15.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.3.1",
46
+ "version": "2.4.0",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -1,6 +1,8 @@
1
1
  // Feature gate for the game audio system (docs/audio-plan.md). `_creatorAudio` is OPTIONAL as a
2
2
  // block: a host without it (or whose engine failed to open a device) makes every Sound silent and
3
- // every Voice inert — a project stays runnable, nothing throws.
3
+ // every Voice inert — a project stays runnable, nothing throws. hasSupport() opens no device: the
4
+ // native engine is created by the first call that needs it (a load, a bus write), so reading this
5
+ // at load costs nothing; a host whose device then fails to open hands Sound.load the silent clip.
4
6
 
5
7
  export const audioSupported: boolean =
6
8
  typeof _creatorAudio !== "undefined" && !!_creatorAudio && typeof _creatorAudio.hasSupport === "function" && _creatorAudio.hasSupport()
@@ -13,27 +13,32 @@ declare global {
13
13
  // CREATOR_PKG_AUDIO (desktop first; Android / Apple pending — parity `audio-game`). The headless
14
14
  // renderer provides a RECORDER (plays are logged, nothing is mixed). Every parameter block is a
15
15
  // fixed-order Float32Array (creator-audio.h CAUD_SRC_* / the effect param orders).
16
+ // The engine (and the audio DEVICE) is created LAZILY, by the first call that needs it — the
17
+ // `@guard audioEnsureEngine` on every method that touches engine state (creator-pkg.h
18
+ // "creator-audio host integration"): an app that never makes a sound never opens the device.
19
+ // hasSupport() therefore opens nothing: it is "the bridge is here and no init has failed".
16
20
  /** @engine audio */
17
21
  var _creatorAudio: {
18
- /** @guard audioEnsureEngine @c caudHasSupport */
22
+ /** True on a host with the bridge until an engine init has FAILED (no device at all). Opens
23
+ * nothing: the engine is created by the first call that needs it. @custom */
19
24
  hasSupport(): boolean
20
25
  /** Decode the bytes behind a fetch system id (WAV / MP3 / OGG Vorbis) on the engine's
21
26
  * loader thread; mono unless `stereo`. onDone(clipId, durationSeconds, channels). @custom */
22
27
  loadClip(systemId: bytes, stereo: boolean, onDone: (clipId: number, duration: number, channels: number) => void, onReject: (message: string) => void): void
23
28
  /** @c caudClipRelease */
24
29
  releaseClip(clipId: u32): void
25
- /** Bus id by name (-1 unknown); 0 master, 1 sfx, 2 music, 3 ui, 4 voice. @c caudBusId */
30
+ /** Bus id by name (-1 unknown); 0 master, 1 sfx, 2 music, 3 ui, 4 voice. @guard audioEnsureEngine @c caudBusId */
26
31
  busId(name: string): i32
27
- /** Creates (or finds) an app-defined bus under master. -1 when the 16 slots are full. @c caudBusCreate */
32
+ /** Creates (or finds) an app-defined bus under master. -1 when the 16 slots are full. @guard audioEnsureEngine @c caudBusCreate */
28
33
  createBus(name: string): i32
29
- /** @c caudBusSetVolume */
34
+ /** @guard audioEnsureEngine @c caudBusSetVolume */
30
35
  setBusVolume(bus: i32, volume: f32): void
31
- /** @c caudBusSetMuted */
36
+ /** @guard audioEnsureEngine @c caudBusSetMuted */
32
37
  setBusMuted(bus: i32, muted: boolean): void
33
38
  /** kind 1 reverb [roomSize, damping, width, mix, preDelay] · 2 echo [delay, decay, mix] ·
34
- * 3 lowpass [cutoffHz]; an empty array turns the effect off. Changes are smoothed. @c caudBusSetEffect */
39
+ * 3 lowpass [cutoffHz]; an empty array turns the effect off. Changes are smoothed. @guard audioEnsureEngine @c caudBusSetEffect */
35
40
  setBusEffect(bus: i32, kind: i32, params: Float32Array): void
36
- /** @c caudBusStopAll */
41
+ /** @guard audioEnsureEngine @c caudBusStopAll */
37
42
  stopBus(bus: i32, fade: f32): void
38
43
  /** A 3D emitter. `attachSource` binds it to an entity the engine follows every frame;
39
44
  * `setSourcePosition` places an unbound one (playAt). @guard audioEnsureEngine @c caudSourceCreate */
@@ -41,11 +46,11 @@ declare global {
41
46
  /** @c gl:audioAttachSource */
42
47
  attachSource(sourceId: u32, entityId: u32): void
43
48
  /** [minDistance, maxDistance, rolloff (0 none 1 inverse 2 linear 3 exp), coneInnerDeg,
44
- * coneOuterDeg, coneOuterGain, doppler, spread, occlusion (0/1), bus]. @c caudSourceSetParams */
49
+ * coneOuterDeg, coneOuterGain, doppler, spread, occlusion (0/1), bus]. @guard audioEnsureEngine @c caudSourceSetParams */
45
50
  setSourceParams(sourceId: u32, params: Float32Array): void
46
- /** @c caudSourceSetPose @pack x 3 @rest nullptr */
51
+ /** @guard audioEnsureEngine @c caudSourceSetPose @pack x 3 @rest nullptr */
47
52
  setSourcePosition(sourceId: u32, x: f32, y: f32, z: f32): void
48
- /** @c caudSourceStopAll */
53
+ /** @guard audioEnsureEngine @c caudSourceStopAll */
49
54
  stopSource(sourceId: u32, fade: f32): void
50
55
  /** @c caudSourceVoices */
51
56
  sourceVoices(sourceId: u32): i32
@@ -53,22 +58,22 @@ declare global {
53
58
  destroySource(sourceId: u32): void
54
59
  /** The listener entity; 0 = the active scene camera (the default). @c gl:audioSetListener */
55
60
  setListener(entityId: u32): void
56
- /** @c caudListenerSetDoppler */
61
+ /** @guard audioEnsureEngine @c caudListenerSetDoppler */
57
62
  setListenerOptions(dopplerFactor: f32): void
58
63
  /** HRTF binaural rendering for the `maxVoices` nearest spatial voices (headphones); the rest keep panning. @guard audioEnsureEngine @c caudSetHrtf */
59
64
  setHrtf(enabled: boolean, maxVoices: i32): void
60
65
  /** Whether the engine's time scale (Time.scale) also pitches the sfx bus. Default false (pause = mute only). @guard audioEnsureEngine @c caudSetTimeScalePitch */
61
66
  setTimeScalePitch(enabled: boolean): void
62
67
  /** → a voice token (0 = nothing played: the pool refused, the clip is not ready). bus -1 = the
63
- * source's bus (sfx for 2D). pan is 2D only. @c caudPlay @pack bus CaudPlay */
68
+ * source's bus (sfx for 2D). pan is 2D only. @guard audioEnsureEngine @c caudPlay @pack bus CaudPlay */
64
69
  play(clipId: u32, sourceId: u32, bus: i32, volume: f32, pitch: f32, loop: boolean, priority: i32, fadeIn: f32, startAt: f32, pan: f32): u32
65
- /** @c caudVoiceSetVolume */
70
+ /** @guard audioEnsureEngine @c caudVoiceSetVolume */
66
71
  setVoiceVolume(token: u32, volume: f32): void
67
- /** @c caudVoiceSetPitch */
72
+ /** @guard audioEnsureEngine @c caudVoiceSetPitch */
68
73
  setVoicePitch(token: u32, pitch: f32): void
69
- /** @c caudVoiceSetPan */
74
+ /** @guard audioEnsureEngine @c caudVoiceSetPan */
70
75
  setVoicePan(token: u32, pan: f32): void
71
- /** @c caudVoiceStop */
76
+ /** @guard audioEnsureEngine @c caudVoiceStop */
72
77
  stopVoice(token: u32, fade: f32): void
73
78
  /** @c caudVoicePlaying */
74
79
  voicePlaying(token: u32): boolean
@@ -77,7 +82,7 @@ declare global {
77
82
  /** THE ended listener (single slot per JS world): called once per tick with the tokens of the
78
83
  * voices that ended since the previous tick — natural end, stop, steal. @custom */
79
84
  setOnEnded(callback: ((tokens: Float32Array) => void) | null): void
80
- /** @c caudStopAll */
85
+ /** @guard audioEnsureEngine @c caudStopAll */
81
86
  stopAll(fade: f32): void
82
87
  /** A reverb volume: shape 0 box (dims = full size) / 1 sphere (dims[0] = diameter), in the
83
88
  * entity's local units; `blend` metres of crossfade inside the border; reverb params as for
@@ -85,7 +90,7 @@ declare global {
85
90
  createZone(): u32
86
91
  /** @c gl:audioAttachZone */
87
92
  attachZone(zoneId: u32, entityId: u32): void
88
- /** @c caudZoneSet */
93
+ /** @guard audioEnsureEngine @c caudZoneSet */
89
94
  setZone(zoneId: u32, shape: i32, dims: Float32Array, blend: f32, bus: i32, reverb: Float32Array): void
90
95
  /** @c gl:audioDetachZone, caudZoneDestroy */
91
96
  destroyZone(zoneId: u32): void
@@ -38,6 +38,25 @@ declare global {
38
38
  /** Whether the overlay is on. */
39
39
  statsOverlay?(): boolean
40
40
 
41
+ // ---- The frame rate (device.setFrameRate / setVsync / displayRefreshRate) ---------------------
42
+ /** Cap the host's frame loop at `fps` frames a second (device.setFrameRate): the tick, and the
43
+ * 3D frame with it, runs at most that often. `0` = no cap: the display's own rate, or, with
44
+ * vsync off, as fast as the host renders. A request, not a state — a phone asks its panel for
45
+ * the rate and may be refused; the screen's own maximum still wins. Host state, kept through
46
+ * `_creatorApp.run()`. Optional: a host whose loop has no pacing of its own (web, headless)
47
+ * leaves it out. */
48
+ setFrameRate?(fps: f64): void
49
+ /** Present without waiting for the display's refresh (device.setVsync): off = no vsync wait,
50
+ * tearing possible, and with no cap set the loop renders as fast as it can. Optional: desktop
51
+ * only — a mobile compositor never presents outside the refresh, so the slot is absent there
52
+ * and the SDK answers `false`. */
53
+ setVsync?(enabled: boolean): void
54
+ /** The display's refresh rate in Hz (device.displayRefreshRate): the fastest the screen offers
55
+ * the app — 120 on a 120 Hz phone whatever mode the cap has put it in (an iPhone's 60 unless
56
+ * the app's Info.plist lifts the floor), the monitor's current mode on desktop. Optional: 0
57
+ * where the host cannot tell (web, Linux for now). */
58
+ displayRefreshRate?(): f64
59
+
41
60
  // ---- device.motion (orientation sensor; pull-per-frame model) --------------------------------
42
61
  /** Does this device have the motion sensors (gyro)? Surfaced as device.motion.available. */
43
62
  motionAvailable?(): boolean
@@ -286,6 +286,11 @@ declare global {
286
286
  setInstanceTransforms(entityId: u32, matrices: Float32Array, first: u32): void
287
287
  setInstancedMeshMaterial(entityId: u32, materialId: u32): void
288
288
  setInstancedMeshShadows(entityId: u32, cast: boolean, receive: boolean): void
289
+ /** GLB scatter (gl/Scatter.ts ↔ creator-gl/src/scatter.cpp): the loaded model `sourceId` drawn at `placements.length / 5`
290
+ * placements (x y z, yaw in degrees, uniform scale) under `entityId`, hardware-instanced in cells of `cell` metres
291
+ * with the source's own buffers and material instances. Returns the renderables made (0 = nothing scattered).
292
+ * Absent on a host without it: Scatter then clones the model per placement. @count count=placements/5 */
293
+ createGlbScatter?(entityId: u32, sourceId: u32, placements: Float32Array, cell: f32, castShadows: boolean): u32
289
294
  setCulling(entityId: u32, culling: boolean): void
290
295
  /** Filament RenderableManager::setPriority — the coarse draw order, 0..7 (Mesh.renderPriority).
291
296
  * Optional: an older host leaves everything at the default 4. */
@@ -780,6 +785,12 @@ declare global {
780
785
  /** Distance fade for every instance of the entity's ASSET (present and future): the cards thin out between `start`
781
786
  * and `end` metres from the camera and past `end` the LOD pass takes the instance out of the scene. end 0 = none. */
782
787
  foliageSetFade?(entityId: u32, start: f32, end: f32): void
788
+ /** THE PLANTS' CASTER DISTANCE: a foliage instance casts the sun's shadow only within `distance` metres of the camera
789
+ * (the LOD pass flips its renderables' castShadows); past it a baked ground keeps the shadow. 0 = every plant casts. */
790
+ foliageSetCast?(distance: f32): void
791
+ /** THE GROUND'S COLOUR UNDER THE PLANTS: the terrain's control map (RGBA = its four layers' weights) and each layer's
792
+ * mean colour (12 floats, linear rgb x 4) — a grass blade's base takes the colour of the soil it stands in. Level-wide. */
793
+ foliageSetGroundTint?(terrainId: u32, controlTextureId: u32, colors: Float32Array): void
783
794
  /** @ifdef CREATOR_GL_PHYSICS */
784
795
  physicsConfigure(gx: f32, gy: f32, gz: f32, maxBodies: u32): void
785
796
  /** @c setPhysicsInterpolation @ifdef CREATOR_GL_PHYSICS */
package/src/gl/Foliage.ts CHANGED
@@ -71,6 +71,11 @@ export type FoliageOptions = {
71
71
  /** Coverage held over distance: the cards' alpha × (1 + coverage × mip level), so a far crown does not thin out as its
72
72
  * atlas mips away. Default 0.25, 0 = none; past ~0.5 a crown turns into a solid mass. */
73
73
  coverage?: number
74
+ /** THE PLANTS' CASTER DISTANCE, metres: a plant casts the sun's shadow only this close to the camera (the LOD pass takes
75
+ * the farther ones out of the shadow map; 3 m of hysteresis). Meant for a level with a BAKED ground, which keeps the
76
+ * far shadows while the shadow map holds a fraction of the casters (the Forest's Radeon frame: 6 ms of 39 at 25 m).
77
+ * Default 0 = every plant casts. */
78
+ castDistance?: number
74
79
  }
75
80
 
76
81
  const DEFAULT_WIND: Required<FoliageWind> = { direction: [ 1, 0.3 ], speed: 8.3, turbulence: 0.4, gust: 0.35, gustSpeed: 5.6 }
@@ -88,6 +93,7 @@ const state: Required<Omit<FoliageOptions, "wind">> & { wind: Required<FoliageWi
88
93
  normalMap: 0.5,
89
94
  edgeFade: 0.15,
90
95
  coverage: 0.25,
96
+ castDistance: 0,
91
97
  }
92
98
 
93
99
  const pushWind = (): void => {
@@ -118,6 +124,7 @@ export const Foliage = {
118
124
  if (options.edgeFade !== undefined) { state.edgeFade = options.edgeFade; look = true }
119
125
  if (options.coverage !== undefined) { state.coverage = options.coverage; look = true }
120
126
  if (look) _creator.foliageSetLook?.(state.canopy, state.normalMap, state.edgeFade, state.coverage)
127
+ if (options.castDistance !== undefined) { state.castDistance = options.castDistance; _creator.foliageSetCast?.(state.castDistance) }
121
128
  },
122
129
 
123
130
  /** The wind: fields given replace the current ones; `false` = calm. Live. */
@@ -221,13 +221,18 @@ export class Lightmap {
221
221
  /** The bake bound last (`load`): what `probe` reads rects from. */
222
222
  private static bound: Bound | null = null
223
223
 
224
- /** Register static geometry — a Model, a Mesh, or any node whose subtree holds them. Statics are
225
- * both receivers and occluders in the bake. `key` names the entry in the .bake (default:
226
- * the node's name + a running number, `container#3`); pass one when names are not stable. */
224
+ /** Register static geometry — a Model, a Mesh, a Terrain's node, or any node whose subtree holds them. Statics are
225
+ * both receivers and occluders in the bake (a model without lightmap UVs, or with materials the lightmap shader
226
+ * declines — alpha cards — is an occluder alone; a terrain is ONE rect, built with `Material.terrainLightmap()`).
227
+ * `key` names the entry in the .bake (default: the node's name + a running number, `container#3`); pass one when
228
+ * names are not stable. */
227
229
  static add(node: Node, key?: string): Node {
228
230
  const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
229
231
  const found: Entry[] = []
230
232
  const walk = (n: Node): void => {
233
+ // the node `Terrain.create` returned carries its terrain (Terrain.of reads the same field): the host keeps the
234
+ // terrain's triangles under this node's id for the bake and binds the rect to its chunks
235
+ if ((n as unknown as { _terrain?: unknown })._terrain) { found.push({ key: base, ground: n }); return }
231
236
  if (n instanceof Model) { found.push({ key: base, model: n }); return }
232
237
  if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
233
238
  for (const c of n.children) walk(c)
@@ -451,8 +451,17 @@ export class Material {
451
451
  return Material._folTemplate
452
452
  }
453
453
  private static _folTemplate: Material | null | undefined
454
+ /** Which material a foliage model's CARDS (its alpha-MASK materials) take: `"full"` = `foliage-masked`, the
455
+ * shader with the card's own plane, normal map, edge fade and the ground's baked light; `"lean"` =
456
+ * `foliage-cards`, the same wind with the lighting moved to the vertex (one texture read, the sun's shadow
457
+ * kept) — a third of a forest frame less on a phone-class GPU (2026-10-18). Read when the first foliage
458
+ * model loads; set it before. */
459
+ static foliageCards: "full" | "lean" = "full"
454
460
  static _foliageMaskedTemplate(): Material | null {
455
- if (Material._folMaskedTemplate === undefined) Material._folMaskedTemplate = Material._glbTemplate(_creator.builtinMaterial("foliage-masked"))
461
+ if (Material._folMaskedTemplate === undefined) {
462
+ const lean = Material.foliageCards === "lean" ? _creator.builtinMaterial("foliage-cards") : null
463
+ Material._folMaskedTemplate = Material._glbTemplate(lean ?? _creator.builtinMaterial("foliage-masked"))
464
+ }
456
465
  return Material._folMaskedTemplate
457
466
  }
458
467
  private static _folMaskedTemplate: Material | null | undefined
@@ -0,0 +1,67 @@
1
+ // A loaded model drawn many times — grass, ground cover, a bush kind — by hardware instancing (2026-10-18, the
2
+ // Forest's grass): `new Scatter(kind, placements)` lays the kind's level-0 meshes out at every placement with the
3
+ // kind's OWN material instances, so a foliage kind keeps its wind, its touch, the ground's baked light and its
4
+ // distance fade, and 1 778 clumps cost ~60 draws instead of 1 778. The engine buckets the placements into cells
5
+ // and splits a cell into chunks of the platform's per-draw cap (64 native); a cell past the kind's fade distance
6
+ // leaves the scene. The chunks are children of this node: add it to a scene, move it, destroy it like any node.
7
+ //
8
+ // const grass = await Model.load(asset('./kinds/grass.glb'), { foliage: true }) // the KIND: loaded, not added
9
+ // scene.add(new Scatter(grass, clumps)) // clumps: { position, yaw, scale }[]
10
+ //
11
+ // The kind itself need not be in a scene. Placements are fixed at construction — a carpet, not a crowd that moves
12
+ // (InstancedMesh is the one with live transforms). On a host without the engine call the placements become clones
13
+ // of the model under this node, the same picture at the old cost.
14
+
15
+ import { Node } from "./Node"
16
+ import type { Model } from "./Model"
17
+ import { cx, cy, cz, type Vec3Like } from "../math/vec"
18
+
19
+ /** One placement: where the kind's origin stands, its yaw about +Y in degrees, its uniform scale. */
20
+ export type ScatterPlacement = {
21
+ position: Vec3Like
22
+ /** Degrees, default 0. */
23
+ yaw?: number
24
+ /** Uniform, default 1. */
25
+ scale?: number
26
+ }
27
+
28
+ export type ScatterOptions = {
29
+ /** The cell edge in metres the placements are bucketed by (one draw per 64 placements of a cell). Default 16. */
30
+ cell?: number
31
+ /** Whether the copies cast the sun's shadow. Default false: ground cover never does. */
32
+ castShadows?: boolean
33
+ name?: string
34
+ }
35
+
36
+ export class Scatter extends Node {
37
+ /** Placements given. */
38
+ readonly count: number
39
+ /** Renderables the engine made (0 on a host that cloned instead). */
40
+ readonly draws: number
41
+
42
+ constructor(kind: Model, placements: readonly ScatterPlacement[], options: ScatterOptions = {}) {
43
+ super()
44
+ this.count = placements.length
45
+ if (options.name !== undefined) this.name = options.name
46
+ const data = new Float32Array(placements.length * 5)
47
+ for (let i = 0; i < placements.length; i++) {
48
+ const p = placements[i]
49
+ data[i * 5] = cx(p.position); data[i * 5 + 1] = cy(p.position); data[i * 5 + 2] = cz(p.position)
50
+ data[i * 5 + 3] = p.yaw ?? 0
51
+ data[i * 5 + 4] = p.scale ?? 1
52
+ }
53
+ const made = placements.length > 0 ? _creator.createGlbScatter?.(this.id, kind.id, data, options.cell ?? 16, options.castShadows ?? false) : 0
54
+ this.draws = made ?? 0
55
+ if (made === undefined && placements.length > 0) {
56
+ // no engine call on this host: the old way, a clone per placement under this node
57
+ for (const p of placements) {
58
+ const m = kind.clone()
59
+ m.position = p.position
60
+ m.eulerAngles = [ 0, p.yaw ?? 0, 0 ]
61
+ m.scale = p.scale ?? 1
62
+ if (!(options.castShadows ?? false)) m.castShadows = false
63
+ this.add(m)
64
+ }
65
+ }
66
+ }
67
+ }
@@ -60,6 +60,9 @@ export type TerrainLayer = {
60
60
  /** Sample this layer triplanar in world space (cliff rock): no stretching on steep slopes; its
61
61
  * normal map is then skipped. Default false. */
62
62
  triplanar?: boolean
63
+ /** The layer's MEAN COLOUR, for the plants: a grass blade's base takes the colour of the soil it stands in (the
64
+ * vegetation tier's cards read the control map and these four colours). Unset on every layer = no tint. */
65
+ color?: ColorInput
63
66
  }
64
67
 
65
68
  export type TerrainOptions = {
@@ -864,6 +867,7 @@ export class Terrain {
864
867
  this._controlTex.wrapS = 0
865
868
  this._controlTex.wrapT = 0
866
869
  this.material.set("control", this._controlTex)
870
+ this._pushGroundTint()
867
871
  return
868
872
  }
869
873
  const r = region ?? { x0: 0, z0: 0, x1: this.sizeX - 1, z1: this.sizeZ - 1 }
@@ -873,6 +877,19 @@ export class Terrain {
873
877
  _creator.updateTexturePixels(this._controlTex._h.id, r.x0, r.z0, w, h, sub)
874
878
  }
875
879
 
880
+ /** The layers' colours to the vegetation tier (TerrainLayer.color), once the control map exists. */
881
+ private _pushGroundTint(): void {
882
+ if (!this._controlTex || !this.layers.some((l) => l.color !== undefined)) return
883
+ const data = new Float32Array(12)
884
+ const lin = (v: number): number => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
885
+ this.layers.forEach((l, i) => {
886
+ const c = l.color !== undefined ? Color.tryParse(l.color) : null
887
+ if (!c) return
888
+ data[i * 3] = lin(c[0]); data[i * 3 + 1] = lin(c[1]); data[i * 3 + 2] = lin(c[2])
889
+ })
890
+ _creator.foliageSetGroundTint?.(this.node.id, this._controlTex._h.id, data)
891
+ }
892
+
876
893
  private async _applyLayers(normals?: TerrainOptions["normals"]): Promise<void> {
877
894
  const m = this.material
878
895
  const tiling = [ 8, 8, 8, 8 ], rough = [ 1, 1, 1, 1 ], nscale = [ 0, 0, 0, 0 ], tri = [ 0, 0, 0, 0 ]
package/src/inject.ts CHANGED
@@ -246,6 +246,8 @@ export type { LocomotionSet, LocomotionClip, LocomotionTuning, LocomotionState,
246
246
  export { Material } from "./gl/Material"
247
247
  export { Geometry } from "./gl/Geometry"
248
248
  export { InstancedMesh } from "./gl/InstancedMesh"
249
+ export { Scatter } from "./gl/Scatter"
250
+ export type { ScatterPlacement, ScatterOptions } from "./gl/Scatter"
249
251
  export type { InstanceTransform } from "./gl/InstancedMesh"
250
252
  export { Texture } from "./gl/Texture"
251
253
  export type { TextureLoadOptions } from "./gl/Texture"
@@ -142,6 +142,35 @@ export const device = {
142
142
  set statsOverlay(on: boolean) {
143
143
  _creatorDevice.setStatsOverlay?.(!!on)
144
144
  },
145
+ /** Cap the frame rate: the host runs the frame — the tick, the 3D scene — at most `fps` times a
146
+ * second. `0` removes the cap: the display's own rate (120 on a 120 Hz phone), or, with vsync
147
+ * off, as fast as the host renders. A REQUEST, not a state the app owns: a phone asks its panel
148
+ * for the rate (a 120 Hz panel at 60 shows every frame for a full period — even, cooler) and an
149
+ * OEM may refuse the switch, in which case the host renders every second refresh instead; the
150
+ * screen's own maximum always wins. The tick IS the frame here, so a cap of 30 is 30 Hz of game
151
+ * logic and input sampling too — raise it for gameplay. Host state: it stays through a project
152
+ * swap. A no-op on hosts whose loop has no pacing of its own (web, headless). */
153
+ setFrameRate(fps: number): void {
154
+ const n = +fps
155
+ _creatorDevice.setFrameRate?.(n > 0 && Number.isFinite(n) ? n : 0)
156
+ },
157
+ /** Vertical sync: `false` presents without waiting for the display's refresh — the lowest input
158
+ * latency, tearing possible, and with no cap set (`setFrameRate(0)`) the loop renders as fast as
159
+ * it can. Set a cap unless you are measuring. Desktop only: a mobile compositor never presents
160
+ * outside the refresh. Returns whether the host took the request (`false` on a host without
161
+ * the switch), so a settings menu can hide the toggle. */
162
+ setVsync(enabled: boolean): boolean {
163
+ if (!_creatorDevice.setVsync) return false
164
+ _creatorDevice.setVsync(!!enabled)
165
+ return true
166
+ },
167
+ /** The display's refresh rate in Hz: the fastest the screen offers the app — 120 on a 120 Hz
168
+ * phone whatever mode a cap has put it in (an iPhone's 60 unless the app's Info.plist lifts the
169
+ * floor), the monitor's current mode on desktop. The sensible choices of a frame-rate menu are
170
+ * its divisors. 0 where the host cannot tell (web). */
171
+ get displayRefreshRate(): number {
172
+ return _creatorDevice.displayRefreshRate?.() ?? 0
173
+ },
145
174
  /** Device-orientation sensor (gyro + accelerometer, fused) for tilt/steering and magic-window /
146
175
  * 360° panoramas. Poll `attitude` / `gravity` inside setLoop; they return the freshest fused
147
176
  * sample, so the sensor rate need not match your frame rate. Host-gated: a no-op with no sensor. */
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.3.1"
7
+ export const SDK_VERSION = "2.4.0"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {