lecodes-sdk 2.3.1 → 2.4.1
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/dist/global.d.ts +4 -0
- package/dist/types/gl/Foliage.d.ts +5 -0
- package/dist/types/gl/Lightmap.d.ts +5 -3
- package/dist/types/gl/Material.d.ts +6 -0
- package/dist/types/gl/Scatter.d.ts +25 -0
- package/dist/types/gl/terrain/Terrain.d.ts +5 -0
- package/dist/types/inject.d.ts +2 -0
- package/dist/types/runtime/device.d.ts +20 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/src/audio/support.ts +3 -1
- package/src/bridges/audio.d.ts +23 -18
- package/src/bridges/device.d.ts +19 -0
- package/src/bridges/gl.d.ts +11 -0
- package/src/compile/liteMaterial.ts +6 -1
- package/src/gl/Foliage.ts +7 -0
- package/src/gl/Lightmap.ts +8 -3
- package/src/gl/Material.ts +10 -1
- package/src/gl/Scatter.ts +67 -0
- package/src/gl/terrain/Terrain.ts +17 -0
- package/src/inject.ts +2 -0
- package/src/runtime/device.ts +29 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/audio/support.ts
CHANGED
|
@@ -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()
|
package/src/bridges/audio.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/src/bridges/device.d.ts
CHANGED
|
@@ -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
|
package/src/bridges/gl.d.ts
CHANGED
|
@@ -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 */
|
|
@@ -33,6 +33,10 @@ export type LiteMaterialArtifact = {
|
|
|
33
33
|
/** Filament's shadow-catcher flag (unlit only, transparency mandatory — matc enforces both):
|
|
34
34
|
* the fragment shows baseColor scaled by the received shadow amount, transparent elsewhere. */
|
|
35
35
|
shadowMultiplier: boolean
|
|
36
|
+
/** matc's `flipUV` (DEFAULT TRUE in Filament's MaterialBuilder): getUV0() / getUV1() return
|
|
37
|
+
* `(u, 1 - v)` of the vertex attribute, the raw attribute only when the material says
|
|
38
|
+
* `flipUV: false`. A .mat written against a native host counts on it; lite's getters follow. */
|
|
39
|
+
flipUV: boolean
|
|
36
40
|
params: LiteMaterialParam[]
|
|
37
41
|
/** sampler2d / samplerExternal parameters — all become sampler2D in lite */
|
|
38
42
|
samplers: { name: string }[]
|
|
@@ -178,7 +182,7 @@ const SAMPLER_TYPES = new Set(["sampler2d", "samplerExternal"])
|
|
|
178
182
|
const unsupported = (reason: string): LiteMaterialArtifact => ({
|
|
179
183
|
lecodesLiteMaterial: 1, unsupported: reason,
|
|
180
184
|
shadingModel: "unlit", blending: "opaque", maskThreshold: 0.4,
|
|
181
|
-
doubleSided: false, culling: "back", depthWrite: true, shadowMultiplier: false,
|
|
185
|
+
doubleSided: false, culling: "back", depthWrite: true, shadowMultiplier: false, flipUV: true,
|
|
182
186
|
params: [], samplers: [], fragmentCode: "", writesNormal: false, hasVertexBlock: false,
|
|
183
187
|
})
|
|
184
188
|
|
|
@@ -235,6 +239,7 @@ export const buildLiteMaterialArtifact = (text: string): LiteMaterialArtifact =>
|
|
|
235
239
|
culling,
|
|
236
240
|
depthWrite,
|
|
237
241
|
shadowMultiplier: def.shadowMultiplier === true,
|
|
242
|
+
flipUV: def.flipUV !== false,
|
|
238
243
|
params,
|
|
239
244
|
samplers,
|
|
240
245
|
fragmentCode,
|
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. */
|
package/src/gl/Lightmap.ts
CHANGED
|
@@ -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
|
|
226
|
-
*
|
|
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)
|
package/src/gl/Material.ts
CHANGED
|
@@ -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)
|
|
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"
|
package/src/runtime/device.ts
CHANGED
|
@@ -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.
|
|
7
|
+
export const SDK_VERSION = "2.4.1"
|
|
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 => {
|