lecodes-sdk 0.20.0 → 0.20.2
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 +31 -0
- package/dist/inject.js +361 -260
- package/dist/types/audio/Bus.d.ts +45 -0
- package/dist/types/audio/Sound.d.ts +28 -0
- package/dist/types/audio/Voice.d.ts +27 -0
- package/dist/types/audio/audio.d.ts +83 -0
- package/dist/types/audio/support.d.ts +1 -0
- package/dist/types/gl/AudioSource.d.ts +60 -0
- package/dist/types/gl/AudioZone.d.ts +32 -0
- package/dist/types/gl/DecalSet.d.ts +103 -0
- package/dist/types/gl/Geometry.d.ts +5 -0
- package/dist/types/gl/Light.d.ts +7 -0
- package/dist/types/gl/Material.d.ts +86 -2
- package/dist/types/gl/Mesh.d.ts +11 -0
- package/dist/types/gl/Scene.d.ts +23 -0
- package/dist/types/gl/SceneAudio.d.ts +11 -0
- package/dist/types/gl/Texture.d.ts +29 -1
- package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
- package/dist/types/gl/animation/core.d.ts +15 -10
- package/dist/types/gl/state.d.ts +0 -1
- package/dist/types/inject.d.ts +10 -0
- package/dist/types/runtime/input.d.ts +11 -0
- package/dist/types/ui/UIImage.d.ts +15 -5
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/dist/2d-game.md +408 -197
- package/prompts/dist/3d-app.md +491 -166
- package/prompts/dist/ar-app.md +373 -163
- package/prompts/dist/design.md +83 -87
- package/prompts/dist/ui-app.md +325 -136
- package/src/audio/Bus.ts +102 -0
- package/src/audio/Sound.ts +96 -0
- package/src/audio/Voice.ts +102 -0
- package/src/audio/audio.ts +161 -0
- package/src/audio/support.ts +6 -0
- package/src/bridges.d.ts +1481 -1352
- package/src/compile/compileProject.ts +30 -15
- package/src/compile/index.ts +3 -0
- package/src/core/Aspect.ts +33 -8
- package/src/g2/Scene2D.ts +7 -0
- package/src/gl/AudioSource.ts +113 -0
- package/src/gl/AudioZone.ts +75 -0
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/DecalSet.ts +233 -0
- package/src/gl/Geometry.ts +5 -0
- package/src/gl/Light.ts +16 -0
- package/src/gl/Lightmap.ts +3 -2
- package/src/gl/Material.ts +152 -4
- package/src/gl/Mesh.ts +20 -1
- package/src/gl/Ragdoll.ts +270 -270
- package/src/gl/Scene.ts +41 -7
- package/src/gl/SceneAudio.ts +26 -0
- package/src/gl/Texture.ts +43 -3
- package/src/gl/Trigger.ts +45 -45
- package/src/gl/Vehicle.ts +5 -5
- package/src/gl/animation/AnimationClip.ts +43 -20
- package/src/gl/animation/Animator.ts +4 -3
- package/src/gl/animation/core.ts +20 -15
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/state.ts +1 -1
- package/src/inject.ts +12 -0
- package/src/runtime/input.ts +6 -1
- package/src/scene/gizmos.ts +148 -148
- package/src/ui/UIImage.ts +21 -7
package/src/gl/Scene.ts
CHANGED
|
@@ -13,8 +13,10 @@ import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
|
|
|
13
13
|
import type { FetchResponse } from "../runtime/fetch"
|
|
14
14
|
import { _requestCameraPermission } from "../plugins/permission"
|
|
15
15
|
import { Camera } from "./Camera"
|
|
16
|
+
import { SceneAudio } from "./SceneAudio"
|
|
16
17
|
import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
|
|
17
18
|
import { Material } from "./Material"
|
|
19
|
+
import { Texture } from "./Texture"
|
|
18
20
|
import { Node, nodeRegistry } from "./Node"
|
|
19
21
|
import { glState } from "./state"
|
|
20
22
|
import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
|
|
@@ -107,6 +109,9 @@ export type SceneOptions = {
|
|
|
107
109
|
* takes over). Unset keeps the host default (desktop 4×, mobile/web off). The biggest single
|
|
108
110
|
* fill-rate cost after resolution — turn it down on big screens before anything else. */
|
|
109
111
|
antialias?: boolean | 2 | 4
|
|
112
|
+
/** Keep a stencil buffer for this scene (off by default: it costs memory and a clear per frame).
|
|
113
|
+
* Needed before any material's `stencil` test or write does anything. */
|
|
114
|
+
stencil?: boolean
|
|
110
115
|
/** Render the 3D at this fraction of the viewport (0.25–1) and upscale; the UI stays at native
|
|
111
116
|
* resolution. A fixed, predictable cut of per-pixel GPU work — `0.75` is ~45 % cheaper and
|
|
112
117
|
* barely visible in motion, `0.5` quarters it. Headless renders ignore it. */
|
|
@@ -128,6 +133,10 @@ export type SceneOptions = {
|
|
|
128
133
|
* scene file — `env` is applied before the nodes build) and leaves already-loaded ones alone.
|
|
129
134
|
* On the desktop host `CREATOR_TEXTURE_ANISOTROPY` overrides it, for tuning without a rebuild. */
|
|
130
135
|
anisotropy?: number
|
|
136
|
+
/** Engine-wide cap on texture size, 0 / unset = none (see `Texture.maxSize`): a KTX2 above it
|
|
137
|
+
* loses its top mip levels on load, a glTF image is downsampled. Applied before this scene's
|
|
138
|
+
* assets load; like `anisotropy` it does not touch textures already loaded. */
|
|
139
|
+
maxTextureSize?: number
|
|
131
140
|
/** The look on an HDR display (a screen with headroom above SDR white — Apple XDR panels, the
|
|
132
141
|
* macOS host today); ignored on SDR. `strength` 0..1 is how much of the picture reaches for the
|
|
133
142
|
* display's headroom (0 only what SDR clipped, 1 nearly everything; default 0.35). `paperWhite`
|
|
@@ -148,17 +157,16 @@ const installRenderSyncedFrames = (): void => {
|
|
|
148
157
|
framesInstalled = true
|
|
149
158
|
_installAspectFrames((early, late) => {
|
|
150
159
|
if (typeof _creator.setEarlyUpdate !== "function") return false
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
_creator.setEarlyUpdate(
|
|
160
|
+
// (The per-phase read-cache stamp — `_phaseStamp` in core/Aspect.ts — advances inside the phase
|
|
161
|
+
// runners themselves, so it is right on this path and on the setLoop fallback alike.)
|
|
162
|
+
_creator.setEarlyUpdate(early)
|
|
154
163
|
_creator.setLateUpdate(late)
|
|
155
164
|
return true
|
|
156
165
|
}, (fixed) => {
|
|
157
166
|
// The fixed phase runs INSIDE the engine's substep loop (creator-physics' frame protocol) when the host has the
|
|
158
|
-
// hook
|
|
159
|
-
// an older host returns false and the dispatcher steps its own 1/60 accumulator.
|
|
167
|
+
// hook. Feature-detected: an older host returns false and the dispatcher steps its own 1/60 accumulator.
|
|
160
168
|
if (typeof _creator.setFixedUpdate !== "function") return false
|
|
161
|
-
_creator.setFixedUpdate(
|
|
169
|
+
_creator.setFixedUpdate(fixed)
|
|
162
170
|
return true
|
|
163
171
|
})
|
|
164
172
|
// Time.scale / Time.paused reach the engine's own clocks (physics, animators, particles) through
|
|
@@ -172,6 +180,8 @@ export class Scene implements Presentable {
|
|
|
172
180
|
/** @internal the IBL intensity this scene was built with (Lightmap.load's ambient floor). */
|
|
173
181
|
_environmentIntensity = 20000
|
|
174
182
|
readonly camera: Camera
|
|
183
|
+
/** The listener + global 3D audio knobs (docs/audio-plan.md). */
|
|
184
|
+
readonly audio: SceneAudio = new SceneAudio()
|
|
175
185
|
|
|
176
186
|
/** @internal touch listeners, read by the dispatch system. */
|
|
177
187
|
readonly _clickListeners: Array<(ev: ClickEvent<Node | null>) => void> = []
|
|
@@ -220,9 +230,11 @@ export class Scene implements Presentable {
|
|
|
220
230
|
// `false` must reach the host: desktop defaults to 4× MSAA, so an explicit off is a real change.
|
|
221
231
|
_creator.setSceneMultiSampleAntiAliasing(this._id, a !== false, typeof a === "number" ? a : 4)
|
|
222
232
|
}
|
|
233
|
+
if (options.stencil !== undefined) this.setStencil(options.stencil)
|
|
223
234
|
// Before anything else in this constructor that could load a texture: the value has to be in
|
|
224
235
|
// place by the time assets bind their samplers.
|
|
225
|
-
if (options.anisotropy !== undefined)
|
|
236
|
+
if (options.anisotropy !== undefined) Texture.anisotropy = options.anisotropy
|
|
237
|
+
if (options.maxTextureSize !== undefined) Texture.maxSize = options.maxTextureSize
|
|
226
238
|
if (options.hdr) {
|
|
227
239
|
if (options.hdr.strength !== undefined) device.hdr.strength = options.hdr.strength
|
|
228
240
|
if (options.hdr.paperWhite !== undefined) device.hdr.paperWhite = options.hdr.paperWhite
|
|
@@ -280,6 +292,28 @@ export class Scene implements Presentable {
|
|
|
280
292
|
setAntialias(enabled: boolean, scale = 4): void {
|
|
281
293
|
_creator.setSceneMultiSampleAntiAliasing(this._id, enabled, scale)
|
|
282
294
|
}
|
|
295
|
+
/** Depth-reading effects on / off (a graphics-settings menu): soft particles and projected decals
|
|
296
|
+
* read the scene depth, which costs a depth pre-pass of every opaque draw (~12 % of a fill-bound
|
|
297
|
+
* frame). Off = hard-edged particles, no decals, no pre-pass. Engine-wide, live. */
|
|
298
|
+
setDepthEffects(enabled: boolean): void {
|
|
299
|
+
_creator.setDepthEffects?.(enabled)
|
|
300
|
+
}
|
|
301
|
+
/** LOD distance (a graphics-settings menu): the engine's LOD thresholds × `bias`. 2 = every level
|
|
302
|
+
* switches at half the distance (a model must look twice as big on screen to keep its detail),
|
|
303
|
+
* 0.5 = full detail twice as far, 1 = the defaults. Engine-wide, live. Only GLBs that carry
|
|
304
|
+
* `_LOD<n>` meshes (`lecodes assets doctor --lod`) have levels to switch. */
|
|
305
|
+
setLodBias(bias: number): void {
|
|
306
|
+
_creator.setLodBias?.(bias)
|
|
307
|
+
}
|
|
308
|
+
/** Runtime form of `bloom` / `bloomIntensity` (a graphics-settings menu). */
|
|
309
|
+
setBloom(enabled: boolean, intensity = 0.2): void {
|
|
310
|
+
_creator.setBloomOptions(this._id, enabled, intensity, 1)
|
|
311
|
+
}
|
|
312
|
+
/** The scene's stencil buffer on / off (see `SceneOptions.stencil`). */
|
|
313
|
+
setStencil(enabled: boolean): void {
|
|
314
|
+
if (_creator.setSceneStencil) _creator.setSceneStencil(this._id, enabled)
|
|
315
|
+
else if (enabled) console.warn("[scene] this host has no setSceneStencil — stencil ignored")
|
|
316
|
+
}
|
|
283
317
|
|
|
284
318
|
add(...nodes: Node[]): this {
|
|
285
319
|
for (const n of nodes) _creator.addEntityToScene(this._id, n.id)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// `scene.audio` (docs/audio-plan.md §2): the listener and the global 3D knobs. The listener is the
|
|
2
|
+
// scene's active camera by default — a `CameraPlace` switch or a follow rig changes nothing, the
|
|
3
|
+
// engine reads whatever camera it renders with. Set a node to listen from somewhere else (the
|
|
4
|
+
// player's head in a third-person game, so sounds pan around the character, not the camera).
|
|
5
|
+
|
|
6
|
+
import { audioSupported } from "../audio/support"
|
|
7
|
+
import type { Node } from "./Node"
|
|
8
|
+
|
|
9
|
+
export class SceneAudio {
|
|
10
|
+
private _listener: Node | null = null
|
|
11
|
+
private _doppler = 1
|
|
12
|
+
|
|
13
|
+
/** The node the engine listens from; null (default) = the active camera. */
|
|
14
|
+
get listener(): Node | null { return this._listener }
|
|
15
|
+
set listener(node: Node | null) {
|
|
16
|
+
this._listener = node
|
|
17
|
+
if (audioSupported) _creatorAudio.setListener(node ? node.id : 0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Multiplies every source's doppler amount (0 = off everywhere). Default 1. */
|
|
21
|
+
get dopplerFactor(): number { return this._doppler }
|
|
22
|
+
set dopplerFactor(v: number) {
|
|
23
|
+
this._doppler = v
|
|
24
|
+
if (audioSupported) _creatorAudio.setListenerOptions(v)
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/gl/Texture.ts
CHANGED
|
@@ -2,7 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
import { fetch, type FetchResponse, type File } from "../runtime/fetch"
|
|
4
4
|
|
|
5
|
+
/** `Texture.load` options. */
|
|
6
|
+
export type TextureLoadOptions = {
|
|
7
|
+
/** `true` (default): colour, stored sRGB. `false`: data (normal map, mask, heightmap) — kept linear. */
|
|
8
|
+
srgb?: boolean
|
|
9
|
+
/** Ignore `Texture.maxSize` for this texture (a lightmap page, a lookup table). */
|
|
10
|
+
fullSize?: boolean
|
|
11
|
+
}
|
|
12
|
+
/** `_creator.createTexture` flags bit: linear data (creator-gl CREATOR_TEXTURE_LINEAR). */
|
|
13
|
+
const TEXTURE_FLAG_LINEAR = 1
|
|
14
|
+
/** `_creator.createTexture` flags bit: exempt from `Texture.maxSize` (creator-gl CREATOR_TEXTURE_FULL_SIZE). */
|
|
15
|
+
const TEXTURE_FLAG_FULL_SIZE = 2
|
|
16
|
+
|
|
5
17
|
export class Texture {
|
|
18
|
+
static _maxSize = 0
|
|
19
|
+
/** Engine-wide cap on texture size (a "texture quality" setting), 0 = none. A KTX2 wider or
|
|
20
|
+
* taller than this loses its top mip levels on load (nothing resampled, less memory and
|
|
21
|
+
* bandwidth), a glTF PNG/JPEG is downsampled. Reaches textures loaded AFTER it is set — a loaded
|
|
22
|
+
* level keeps its textures — so set it up front (`SceneOptions.maxTextureSize`, or before the
|
|
23
|
+
* level loads) and apply a menu change on the next level load. Lightmap pages are exempt
|
|
24
|
+
* (`TextureLoadOptions.fullSize`). A host may pin it (desktop `CREATOR_TEXTURE_MAX_SIZE`). */
|
|
25
|
+
static _anisotropy = 2
|
|
26
|
+
/** Engine-wide anisotropic filtering, 1 (off) … 16 (default 2; `SceneOptions.anisotropy` sets it up
|
|
27
|
+
* front). A sampler is baked when its texture is bound, so like `maxSize` this reaches textures
|
|
28
|
+
* loaded AFTER it — set it before the level loads. Measured on a lightmapped interior at 720p:
|
|
29
|
+
* 4× costs ~20 % of the frame over 1× on an integrated GPU. A host may pin it. */
|
|
30
|
+
static get anisotropy(): number { return Texture._anisotropy }
|
|
31
|
+
static set anisotropy(level: number) {
|
|
32
|
+
Texture._anisotropy = level >= 1 ? Math.min(16, level) : 1
|
|
33
|
+
_creator.setTextureAnisotropy?.(Texture._anisotropy)
|
|
34
|
+
}
|
|
35
|
+
static get maxSize(): number { return Texture._maxSize }
|
|
36
|
+
static set maxSize(size: number) {
|
|
37
|
+
Texture._maxSize = size > 0 ? Math.floor(size) : 0
|
|
38
|
+
_creator.setTextureMaxSize?.(Texture._maxSize)
|
|
39
|
+
}
|
|
6
40
|
/** @internal native texture handle. */
|
|
7
41
|
readonly _id: number
|
|
8
42
|
readonly width: number
|
|
@@ -47,17 +81,23 @@ export class Texture {
|
|
|
47
81
|
_creator.updateTexturePixels?.(this._id, x, y, width, height, data)
|
|
48
82
|
}
|
|
49
83
|
|
|
50
|
-
|
|
84
|
+
/** Decode an image (PNG / JPG, or a KTX2 the core transcodes) into a texture. `srgb` (default
|
|
85
|
+
* true) says the bytes are COLOUR, stored sRGB so the GPU linearises them on sample; pass
|
|
86
|
+
* `false` for DATA — a normal map, a mask, a heightmap — which must come back as stored (a
|
|
87
|
+
* flat normal read through sRGB bends by ~35°). A KTX2 decides by its own header. Image
|
|
88
|
+
* textures get a mip chain (trilinear) on hosts that build one. */
|
|
89
|
+
static load(source: string | FetchResponse | File, options: TextureLoadOptions = {}): Promise<Texture> {
|
|
51
90
|
if (typeof source === "string") {
|
|
52
91
|
return fetch(source, { useOnce: true }).then((resp) => {
|
|
53
92
|
if (resp.status >= 400) {
|
|
54
93
|
return Promise.reject(new Error(`Failed to fetch texture from ${source}. HTTP ${resp.status}`))
|
|
55
94
|
}
|
|
56
|
-
return Texture.load(resp)
|
|
95
|
+
return Texture.load(resp, options)
|
|
57
96
|
})
|
|
58
97
|
}
|
|
98
|
+
const flags = (options.srgb === false ? TEXTURE_FLAG_LINEAR : 0) | (options.fullSize ? TEXTURE_FLAG_FULL_SIZE : 0)
|
|
59
99
|
return new Promise<Texture>((resolve, reject) => {
|
|
60
|
-
_creator.createTexture((source as any)._id, (id, w, h) => resolve(new Texture(w, h, id)), reject)
|
|
100
|
+
_creator.createTexture((source as any)._id, (id, w, h) => resolve(new Texture(w, h, id)), reject, flags)
|
|
61
101
|
})
|
|
62
102
|
}
|
|
63
103
|
}
|
package/src/gl/Trigger.ts
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
// A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
|
|
2
|
-
// body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
|
|
3
|
-
// instead of colliding. A trigger does not block movement, and is still pointer-pickable.
|
|
4
|
-
//
|
|
5
|
-
// const goal = new Mesh(box(), material)
|
|
6
|
-
// .aspect(Shape, { box: [1, 2, 1] })
|
|
7
|
-
// .aspect(Trigger)
|
|
8
|
-
// goal.addEventListener('enter', other => win(other))
|
|
9
|
-
// goal.addEventListener('exit', other => …)
|
|
10
|
-
|
|
11
|
-
import { Aspect } from "../core/Aspect"
|
|
12
|
-
import { Shape } from "./Shape"
|
|
13
|
-
import { ensurePhysicsEvents } from "./physicsEvents"
|
|
14
|
-
import type { Node } from "./Node"
|
|
15
|
-
|
|
16
|
-
export class Trigger extends Aspect<"trigger", Node> {
|
|
17
|
-
static readonly aspect = "trigger"
|
|
18
|
-
|
|
19
|
-
private _bodyId = 0
|
|
20
|
-
/** Native body id (0 if no physics support). */
|
|
21
|
-
get id(): number { return this._bodyId }
|
|
22
|
-
|
|
23
|
-
onAttach(): void {
|
|
24
|
-
if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
|
|
25
|
-
const shape = this.node.get(Shape)
|
|
26
|
-
if (!shape) {
|
|
27
|
-
throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
|
|
28
|
-
}
|
|
29
|
-
const shapeId = shape._claim()
|
|
30
|
-
this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
|
|
31
|
-
shape._ownBody(this._bodyId)
|
|
32
|
-
ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
onDetach(): void {
|
|
36
|
-
if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
|
|
37
|
-
const shape = this.node.get(Shape)
|
|
38
|
-
if (shape) {
|
|
39
|
-
shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
|
|
40
|
-
shape._recreatePickBody()
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// No moveTo(): `node.position = p` moves the node AND the sensor body it owns (see Node._xf).
|
|
45
|
-
}
|
|
1
|
+
// A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
|
|
2
|
+
// body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
|
|
3
|
+
// instead of colliding. A trigger does not block movement, and is still pointer-pickable.
|
|
4
|
+
//
|
|
5
|
+
// const goal = new Mesh(box(), material)
|
|
6
|
+
// .aspect(Shape, { box: [1, 2, 1] })
|
|
7
|
+
// .aspect(Trigger)
|
|
8
|
+
// goal.addEventListener('enter', other => win(other))
|
|
9
|
+
// goal.addEventListener('exit', other => …)
|
|
10
|
+
|
|
11
|
+
import { Aspect } from "../core/Aspect"
|
|
12
|
+
import { Shape } from "./Shape"
|
|
13
|
+
import { ensurePhysicsEvents } from "./physicsEvents"
|
|
14
|
+
import type { Node } from "./Node"
|
|
15
|
+
|
|
16
|
+
export class Trigger extends Aspect<"trigger", Node> {
|
|
17
|
+
static readonly aspect = "trigger"
|
|
18
|
+
|
|
19
|
+
private _bodyId = 0
|
|
20
|
+
/** Native body id (0 if no physics support). */
|
|
21
|
+
get id(): number { return this._bodyId }
|
|
22
|
+
|
|
23
|
+
onAttach(): void {
|
|
24
|
+
if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
|
|
25
|
+
const shape = this.node.get(Shape)
|
|
26
|
+
if (!shape) {
|
|
27
|
+
throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
|
|
28
|
+
}
|
|
29
|
+
const shapeId = shape._claim()
|
|
30
|
+
this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
|
|
31
|
+
shape._ownBody(this._bodyId)
|
|
32
|
+
ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onDetach(): void {
|
|
36
|
+
if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
|
|
37
|
+
const shape = this.node.get(Shape)
|
|
38
|
+
if (shape) {
|
|
39
|
+
shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
|
|
40
|
+
shape._recreatePickBody()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// No moveTo(): `node.position = p` moves the node AND the sensor body it owns (see Node._xf).
|
|
45
|
+
}
|
package/src/gl/Vehicle.ts
CHANGED
|
@@ -25,13 +25,12 @@
|
|
|
25
25
|
//
|
|
26
26
|
// Layering, and why it is where it is: docs/vehicle-rework-plan.md.
|
|
27
27
|
|
|
28
|
-
import { Aspect } from "../core/Aspect"
|
|
28
|
+
import { Aspect, _phaseStamp } from "../core/Aspect"
|
|
29
29
|
import type { FieldMeta } from "../core/fields"
|
|
30
30
|
import { Mat4 } from "../math/mat4"
|
|
31
31
|
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
32
32
|
import { cw, type QuatLike } from "../math/quat"
|
|
33
33
|
import { Gizmos } from "../scene/gizmos"
|
|
34
|
-
import { glState } from "./state"
|
|
35
34
|
import { Gearbox } from "./Gearbox"
|
|
36
35
|
import { Node } from "./Node"
|
|
37
36
|
import { DEFAULT_FRICTION, Physics } from "./Physics"
|
|
@@ -314,12 +313,13 @@ export class Vehicle extends Aspect<"vehicle", Node> {
|
|
|
314
313
|
if (this._id) _creator.vehicleSetTransmission(this._id, ratio, clamp(clutch, 0, 1))
|
|
315
314
|
}
|
|
316
315
|
|
|
317
|
-
// --- readouts (one native read per
|
|
316
|
+
// --- readouts (one native read per PHASE — early sees the pre-step state, late the settled one —
|
|
317
|
+
// shared by the car and every wheel; keyed on core/Aspect's _phaseStamp) -------------------
|
|
318
318
|
|
|
319
319
|
private _read(): Float32Array {
|
|
320
|
-
if (this._id && this._stateFrame !==
|
|
320
|
+
if (this._id && this._stateFrame !== _phaseStamp.value) {
|
|
321
321
|
_creator.vehicleGetState(this._id, this._state)
|
|
322
|
-
this._stateFrame =
|
|
322
|
+
this._stateFrame = _phaseStamp.value
|
|
323
323
|
}
|
|
324
324
|
return this._state
|
|
325
325
|
}
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// AnimationClip.load(asset('./run.glb')),
|
|
11
11
|
// ])
|
|
12
12
|
// const slash = await AnimationClip.load(asset('./attacks.glb'), 'Slash') // one out of many
|
|
13
|
-
// const bob = AnimationClip.
|
|
13
|
+
// const bob = AnimationClip.fromCurves({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } })
|
|
14
|
+
// const stopM = AnimationClip.from(stop, { mirror: true }) // the other side of the body
|
|
15
|
+
// const kick = AnimationClip.from(take, { from: 0.2, to: 1.1 }) // a window of a longer take
|
|
14
16
|
|
|
15
17
|
import { fetch, type FetchResponse } from "../../runtime/fetch"
|
|
16
18
|
import type { Vec3Like } from "../../math/vec"
|
|
@@ -37,6 +39,20 @@ export type ClipDef = {
|
|
|
37
39
|
|
|
38
40
|
export type ClipInfo = { name: string, duration: number, trackCount: number }
|
|
39
41
|
|
|
42
|
+
/** What `AnimationClip.from(clip, …)` derives from a clip. */
|
|
43
|
+
export type DeriveOptions = {
|
|
44
|
+
/** The clip MIRRORED — left ↔ right, the motion on the other side of the body (a stop that brakes on the
|
|
45
|
+
* left foot brakes on the right). Derived on the rig of the clip's own file (joints pair by name, the
|
|
46
|
+
* sagittal plane comes from the rest pose), so the result is the same on every model: its contacts,
|
|
47
|
+
* phase, root motion and heading are the mirrored ones, its name is `<name>_M`. Only clips from a GLB
|
|
48
|
+
* carry a rig; a curve-built clip cannot be mirrored. */
|
|
49
|
+
mirror?: boolean
|
|
50
|
+
/** Start of the window, seconds of the source (default 0). */
|
|
51
|
+
from?: number
|
|
52
|
+
/** End of the window, seconds of the source (default the clip's end). */
|
|
53
|
+
to?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
const PATH = { position: 0, rotation: 1, scale: 2 } as const
|
|
41
57
|
const INTERP = { linear: 0, step: 1 } as const
|
|
42
58
|
|
|
@@ -90,8 +106,8 @@ export class AnimationClip {
|
|
|
90
106
|
|
|
91
107
|
/** Mark a moment of the clip (SECONDS from its start) with an event name: `kick.addEvent(0.4, 'hit')`
|
|
92
108
|
* → `anim.on('hit', (clip, layer) => …)` fires when the playhead crosses it, loops included.
|
|
93
|
-
* Events are part of the clip: every model playing it gets them; `
|
|
94
|
-
* the
|
|
109
|
+
* Events are part of the clip: every model playing it gets them; `AnimationClip.from(clip, { from, to })`
|
|
110
|
+
* keeps the ones inside the window, re-timed. Chainable. */
|
|
95
111
|
addEvent(time: number, name: string): this {
|
|
96
112
|
this._events.push({ t: Math.min(Math.max(0, time), this.duration), name })
|
|
97
113
|
this._events.sort((a, b) => a.t - b.t)
|
|
@@ -128,7 +144,7 @@ export class AnimationClip {
|
|
|
128
144
|
|
|
129
145
|
/** Build a clip from curves in code — no DCC needed. Keys are `[time, value]`; a track binds to the
|
|
130
146
|
* node of that name when the clip is used by an Animator (bones, or any child node). */
|
|
131
|
-
static
|
|
147
|
+
static fromCurves(def: ClipDef): AnimationClip {
|
|
132
148
|
const names: string[] = []
|
|
133
149
|
const data: number[] = [0, 0]
|
|
134
150
|
let trackCount = 0
|
|
@@ -153,30 +169,37 @@ export class AnimationClip {
|
|
|
153
169
|
if (def.duration !== undefined) duration = def.duration
|
|
154
170
|
data[1] = duration
|
|
155
171
|
const setId = _creator.createClipFromTracks(names.join("\n"), new Float32Array(data))
|
|
156
|
-
if (setId === 0) throw new Error("AnimationClip.
|
|
172
|
+
if (setId === 0) throw new Error("AnimationClip.fromCurves: invalid track data")
|
|
157
173
|
const info = readInfo(setId)[0] ?? { name: "clip", duration, trackCount }
|
|
158
174
|
return new AnimationClip(setId, 0, info)
|
|
159
175
|
}
|
|
160
176
|
|
|
161
|
-
/** A
|
|
162
|
-
* (`
|
|
163
|
-
* action,
|
|
177
|
+
/** A NEW clip derived from `clip`: its mirror (`{ mirror: true }` — the other side of the body), a window
|
|
178
|
+
* of it (`{ from, to }` seconds of the source, re-timed to 0; array-slice semantics), or both. Cut a
|
|
179
|
+
* too-long take down to the action, carve several sub-clips out of one packed timeline, get the
|
|
180
|
+
* left-footed stop from the right-footed one:
|
|
164
181
|
*
|
|
165
|
-
* clips: { Kick:
|
|
182
|
+
* clips: { Kick: AnimationClip.from(kick, { from: 0.2, to: 1.1 }), StopM: AnimationClip.from(stop, { mirror: true }) }
|
|
166
183
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
const st = Math.max(0,
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
* A window drops the keys outside it and interpolates exact boundary values in, so the clip starts and
|
|
185
|
+
* ends precisely on the source's pose at the cut points. Everything downstream (blend spaces, events'
|
|
186
|
+
* times, root motion, foot contacts, phase sync) sees a normal clip. */
|
|
187
|
+
static from(clip: AnimationClip, options: DeriveOptions): AnimationClip {
|
|
188
|
+
const mirror = options.mirror ?? false
|
|
189
|
+
const windowed = options.from !== undefined || options.to !== undefined
|
|
190
|
+
const st = windowed ? Math.max(0, options.from ?? 0) : -1
|
|
191
|
+
const e = windowed ? Math.min(options.to ?? clip.duration, clip.duration) : -1
|
|
192
|
+
const setId = _creator.deriveClip(clip._set, clip._index, mirror, st, e)
|
|
193
|
+
if (setId === 0) {
|
|
194
|
+
const what = windowed ? `bad range ${options.from ?? 0}–${options.to ?? clip.duration}` : "cannot mirror (no rig in the clip's file, or nothing to mirror)"
|
|
195
|
+
throw new Error(`AnimationClip.from: ${what} of '${clip.name}' (${clip.duration.toFixed(2)}s)`)
|
|
196
|
+
}
|
|
197
|
+
const info = readInfo(setId)[0] ?? { name: mirror ? `${clip.name}_M` : clip.name, duration: windowed ? e - st : clip.duration, trackCount: clip.trackCount }
|
|
177
198
|
const out = new AnimationClip(setId, 0, info)
|
|
178
199
|
// the engine re-timed the event TIMES; carry the names the same way
|
|
179
|
-
out._events =
|
|
200
|
+
out._events = windowed
|
|
201
|
+
? clip._events.filter((ev) => ev.t >= st - 1e-6 && ev.t <= e + 1e-6).map((ev) => ({ t: Math.min(Math.max(0, ev.t - st), e - st), name: ev.name }))
|
|
202
|
+
: clip._events.map((ev) => ({ ...ev }))
|
|
180
203
|
return out
|
|
181
204
|
}
|
|
182
205
|
|
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
//
|
|
6
6
|
// const hero = await Model.load(asset('./hero.glb'))
|
|
7
7
|
// hero.anim.playLoop('Idle') // the loop: what the character rests on
|
|
8
|
-
// await hero.anim.play('Jump', {
|
|
8
|
+
// await hero.anim.play('Jump', { fade: 0.2 }) // a one-shot over it, back to the loop after
|
|
9
9
|
//
|
|
10
10
|
// const loco = hero.anim.playLoop({ Idle: 0, Walk: 2, Run: 6 }) // a blend space as the loop
|
|
11
11
|
// loco.value = hero.controller.velocity.length // where we are on its axis
|
|
12
12
|
// const upper = hero.anim.addLayer({ mask: 'Spine1' })
|
|
13
13
|
// upper.playLoop('Aim') // arms aim, legs keep walking
|
|
14
14
|
//
|
|
15
|
-
// Every switch is an inertialized transition: the pose shown
|
|
16
|
-
// new source decays over `fade`
|
|
15
|
+
// Every switch is an inertialized transition: the pose shown (a loop, a one-shot, rest) does not fade
|
|
16
|
+
// away, its difference to the new source decays over `fade` — and no `fade` means a cut, never a
|
|
17
|
+
// default. A clip may be replaced at any time; a one-shot on a layer with no loop holds its last frame.
|
|
17
18
|
//
|
|
18
19
|
// The surface, top to bottom: clips → playing → root motion → the feet and the warp (`anim.feet`,
|
|
19
20
|
// `anim.warp` — locomotion's post-processing of the pose) → level of detail → engine-facing.
|
package/src/gl/animation/core.ts
CHANGED
|
@@ -18,19 +18,17 @@ import type { Node } from "../Node"
|
|
|
18
18
|
/** A position on a blend axis (1D) or plane (2D). */
|
|
19
19
|
export type BlendPosition = number | readonly [number, number]
|
|
20
20
|
|
|
21
|
-
/** The transition time (seconds) a play / playLoop without `fade` uses. */
|
|
22
|
-
export const DEFAULT_FADE = 0.2
|
|
23
|
-
|
|
24
21
|
export type PlayOptions = {
|
|
25
|
-
/** Transition seconds — how long the difference to the pose the layer SHOWED
|
|
26
|
-
*
|
|
22
|
+
/** Transition seconds — how long the difference to the pose the layer SHOWED (its loop, a one-shot,
|
|
23
|
+
* or rest) takes to decay. Default `0` = cut: a fade is always asked for, never implied. Also the
|
|
24
|
+
* transition back to the loop at the end unless `fadeOut` overrides it. */
|
|
27
25
|
fade?: number
|
|
28
26
|
/** Transition seconds for the way in only (overrides `fade`). */
|
|
29
27
|
fadeIn?: number
|
|
30
|
-
/** One-shots: the transition BACK at the end (overrides `fade`
|
|
31
|
-
* clip ends — while it still plays — so the hand-over lands on the
|
|
32
|
-
* layer with no loop
|
|
33
|
-
*
|
|
28
|
+
/** One-shots: the transition BACK to the loop at the end (overrides `fade`; `0` = cut at the end).
|
|
29
|
+
* It starts this long before the clip ends — while it still plays — so the hand-over lands on the
|
|
30
|
+
* last pose, not after it. On a layer with no loop the clip HOLDS its last frame (a rock that broke
|
|
31
|
+
* stays broken) — `anim.stop({ fade })` is the way back to rest. */
|
|
34
32
|
fadeOut?: number
|
|
35
33
|
/** Playback rate for this clip (default 1). */
|
|
36
34
|
speed?: number
|
|
@@ -52,8 +50,8 @@ export type PlayOptions = {
|
|
|
52
50
|
export type StopOptions = { fade?: number }
|
|
53
51
|
|
|
54
52
|
export type LoopOptions = {
|
|
55
|
-
/** Transition seconds from whatever the layer shows — its previous loop,
|
|
56
|
-
*
|
|
53
|
+
/** Transition seconds from whatever the layer shows — its previous loop, a one-shot still playing,
|
|
54
|
+
* or rest. Default `0` = cut: a loop wanting a transition asks for one (`{ fade: 0.2 }`). */
|
|
57
55
|
fade?: number
|
|
58
56
|
/** Playback rate of the loop's clips (default 1). */
|
|
59
57
|
speed?: number
|
|
@@ -145,6 +143,12 @@ export type ClipInfo = {
|
|
|
145
143
|
turnAt(time: number): number
|
|
146
144
|
/** the root's own speed at `time`, m/s — how fast the clip is moving the body right there */
|
|
147
145
|
speedAt(time: number): number
|
|
146
|
+
/** the PELVIS' yaw relative to the clip's heading at `time` (rad, + = left, wrapped to ±π): the stance twist
|
|
147
|
+
* the heading leaves out — an idle stands ~43° off the course it starts along, a stop's tail turns into that
|
|
148
|
+
* stance while the heading holds. A controller that switches clips mid-pose can keep the PELVIS continuous
|
|
149
|
+
* (turn the node by the difference between the two clips' values) where keeping the course would swing
|
|
150
|
+
* the body. Without a baked heading: the pelvis' yaw at frame 0 throughout. */
|
|
151
|
+
pelvisYawAt(time: number): number
|
|
148
152
|
/** the unit travel direction at `time` ([x, z], model space, held through stills) — integrate
|
|
149
153
|
* direction × d(travel) to reconstruct the root's 2D path (a treadmill display, a turn's arc) */
|
|
150
154
|
directionAt(time: number): [number, number]
|
|
@@ -215,7 +219,7 @@ const ensureEvents = (): void => {
|
|
|
215
219
|
if (eventsInstalled) return
|
|
216
220
|
eventsInstalled = true
|
|
217
221
|
_creator.setOnAnimatorEvent((animatorId, slot, type) => handlers.get(animatorId)?.(slot, type))
|
|
218
|
-
_creator.setOnAnimatorStep((animatorId, side, x, y, z) => stepHandlers.get(animatorId)?.(side, x, y, z))
|
|
222
|
+
_creator.setOnAnimatorStep?.((animatorId, side, x, y, z) => stepHandlers.get(animatorId)?.(side, x, y, z))
|
|
219
223
|
}
|
|
220
224
|
|
|
221
225
|
const settled = (slot: SlotRec): PlaybackRec => {
|
|
@@ -364,8 +368,8 @@ export class Core {
|
|
|
364
368
|
const s = this.slotFor(L, name, clip)
|
|
365
369
|
if (!s) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
|
|
366
370
|
if (s.inBlend) { console.warn(`Animator: '${name}' is part of this layer's loop — use playLoop() to change what it rests on`); return new Playback(settled(s), this) }
|
|
367
|
-
const fadeIn = o.fadeIn ?? o.fade ??
|
|
368
|
-
const fadeOut = o.fadeOut ?? o.fade ??
|
|
371
|
+
const fadeIn = o.fadeIn ?? o.fade ?? 0
|
|
372
|
+
const fadeOut = o.fadeOut ?? o.fade ?? 0
|
|
369
373
|
const phase = this.entryPhase(L, o.phase) // read BEFORE the play: what the layer shows now
|
|
370
374
|
this.settleLayer(L) // the layer had one source: whatever it was, this play takes over from it
|
|
371
375
|
let resolve = (_n: boolean): void => {}
|
|
@@ -399,7 +403,7 @@ export class Core {
|
|
|
399
403
|
// ---- loop ----
|
|
400
404
|
loop(L: LayerRec, def: LoopDef, o: LoopOptions): Loop | undefined {
|
|
401
405
|
if (!this.ensure()) return undefined
|
|
402
|
-
const fade = o.fade ??
|
|
406
|
+
const fade = o.fade ?? 0
|
|
403
407
|
const phase = this.entryPhase(L, o.phase) // read BEFORE the members replace the layer's source
|
|
404
408
|
if (L.loop) { L.loop.dead = true; for (const m of L.loop.members) m.slot.inBlend = false; L.loop = undefined }
|
|
405
409
|
this.settleLayer(L) // a one-shot on the layer is cut short by the new loop
|
|
@@ -576,6 +580,7 @@ export class Core {
|
|
|
576
580
|
travelAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 1, time),
|
|
577
581
|
turnAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 2, time),
|
|
578
582
|
speedAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 3, time),
|
|
583
|
+
pelvisYawAt: (time: number) => { const v = _creator.animatorSlotCurveAt(id, slot, 6, time); return Math.atan2(Math.sin(v), Math.cos(v)) },
|
|
579
584
|
directionAt: (time: number) => [ _creator.animatorSlotCurveAt(id, slot, 4, time), _creator.animatorSlotCurveAt(id, slot, 5, time) ] as [number, number],
|
|
580
585
|
timeAtTurn: (yaw: number) => _creator.animatorSlotTimeAtTurn(id, slot, yaw),
|
|
581
586
|
kneePoleAt: (side: "left" | "right", time: number) => {
|