lecodes-sdk 0.19.2 → 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 +62 -0
- package/dist/host.d.ts +3 -0
- 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/Locomotion.d.ts +3 -1
- 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/Animator.d.ts +51 -183
- package/dist/types/gl/animation/Feet.d.ts +85 -0
- package/dist/types/gl/animation/Warp.d.ts +53 -0
- package/dist/types/gl/animation/core.d.ts +61 -17
- package/dist/types/gl/state.d.ts +0 -1
- package/dist/types/inject.d.ts +17 -2
- package/dist/types/plugins/map.d.ts +174 -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/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 -1345
- package/src/compile/__tests__/compile.test.ts +12 -0
- package/src/compile/compileProject.ts +35 -15
- package/src/compile/index.ts +3 -0
- package/src/core/Aspect.ts +34 -9
- package/src/g2/Scene2D.ts +7 -0
- package/src/gl/AudioSource.ts +113 -0
- package/src/gl/AudioZone.ts +75 -0
- 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/Locomotion.ts +7 -5
- package/src/gl/Material.ts +152 -4
- package/src/gl/Mesh.ts +20 -1
- package/src/gl/Particles.ts +3 -3
- package/src/gl/Scene.ts +42 -8
- package/src/gl/SceneAudio.ts +26 -0
- package/src/gl/Texture.ts +43 -3
- package/src/gl/Vehicle.ts +5 -5
- package/src/gl/animation/AnimationClip.ts +43 -20
- package/src/gl/animation/Animator.ts +138 -329
- package/src/gl/animation/Feet.ts +134 -0
- package/src/gl/animation/Loop.ts +3 -1
- package/src/gl/animation/Warp.ts +96 -0
- package/src/gl/animation/core.ts +741 -670
- package/src/gl/state.ts +6 -6
- package/src/host.d.ts +3 -0
- package/src/inject.ts +23 -2
- package/src/plugins/map.ts +396 -0
- package/src/runtime/input.ts +6 -1
- package/src/ui/UIImage.ts +21 -7
|
@@ -285,3 +285,15 @@ test("buildHeader / detectProjectKind signal the engine from bundled code", () =
|
|
|
285
285
|
expect(detectProjectKind("const x = 1")).toBe("ui")
|
|
286
286
|
expect(detectProjectKind("_creator.createARController()")).toBe("ar")
|
|
287
287
|
})
|
|
288
|
+
|
|
289
|
+
test("a .json project file is data — asset()/import gives the parsed value", async () => {
|
|
290
|
+
const js = await compileProject({
|
|
291
|
+
name: "Data", publicUrl: "https://le.codes", header: false,
|
|
292
|
+
entries: [
|
|
293
|
+
{ path: "/main.ts", type: "text", text: 'toast(asset("./style.json").name)\n' },
|
|
294
|
+
{ path: "/style.json", type: "text", text: '{ "name": "OSM Bright", "version": 8 }\n' },
|
|
295
|
+
],
|
|
296
|
+
})
|
|
297
|
+
expect(js).toContain("OSM Bright")
|
|
298
|
+
expect(js).not.toContain('"./style.json"') // desugared to the import, not left as a string
|
|
299
|
+
})
|
|
@@ -69,21 +69,22 @@ export type CompileOptions = {
|
|
|
69
69
|
serverUrl?: string
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (!entry) {
|
|
77
|
-
throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
|
|
78
|
-
}
|
|
72
|
+
/** The bundle + its map SEPARATELY (the platform deploy writes the map as a sibling `.js.map`; the
|
|
73
|
+
* preview inlines it) plus the entrypoint the compile used. `code` carries the metadata header
|
|
74
|
+
* unless `header: false`; `map` is already shifted past that header. */
|
|
75
|
+
export type CompileResult = { code: string, map: string | null, entry: string }
|
|
79
76
|
|
|
77
|
+
/** Turn compile entries into the bundler's module map — the per-kind rules of the deployable
|
|
78
|
+
* bundle (resource → URL export, shader → per-backend URL template, .svg → SvgSource, .json →
|
|
79
|
+
* data, code verbatim). Exported for callers that build a `libraries` map from entries. */
|
|
80
|
+
export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: string, localAssets?: boolean }): Record<string, string> => {
|
|
80
81
|
const files: Record<string, string> = {}
|
|
81
82
|
for (const e of entries) {
|
|
82
83
|
if (e.type === "resource") {
|
|
83
84
|
if (opts.localAssets && !e.remote) {
|
|
84
85
|
// The host resolves the asset by name at runtime (reads the local file → systemId); the
|
|
85
86
|
// "id:" prefix is the host's local-resource URL scheme (parsed by the UI + 2D texture paths).
|
|
86
|
-
const name = e.path.split(/[
|
|
87
|
+
const name = e.path.split(/[\/]/).pop() ?? e.path
|
|
87
88
|
files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`
|
|
88
89
|
} else {
|
|
89
90
|
files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
|
|
@@ -98,7 +99,7 @@ export const compileProject = async (opts: CompileOptions): Promise<string> => {
|
|
|
98
99
|
// desktop host reports "desktop" on Windows and "opengl" on Linux, iOS "metal".
|
|
99
100
|
// Only entries the CLI actually compiled+staged carry `shadersLocal`; server artifacts
|
|
100
101
|
// (URLs) and un-staged ones keep the URL forms below.
|
|
101
|
-
const name = shaders[0].src.split(/[
|
|
102
|
+
const name = shaders[0].src.split(/[\/]/).pop() ?? ""
|
|
102
103
|
const [prefix, suffix = ".filamat"] = name.split("_opengl")
|
|
103
104
|
files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(prefix + "_")} + _creator.backend + ${JSON.stringify(suffix)}))`
|
|
104
105
|
} else if (shaders.length > 0) {
|
|
@@ -108,20 +109,39 @@ export const compileProject = async (opts: CompileOptions): Promise<string> => {
|
|
|
108
109
|
}
|
|
109
110
|
} else if (e.path.toLowerCase().endsWith(".svg")) {
|
|
110
111
|
files[e.path] = `export default SvgSource(\`${e.text ?? ""}\`)`
|
|
112
|
+
} else if (e.path.toLowerCase().endsWith(".json")) {
|
|
113
|
+
// A .json project file is DATA, not code: `import data from "./x.json"` — and `asset("./x.json")`,
|
|
114
|
+
// which desugars to that import — yields the parsed value. The bundler would otherwise try to
|
|
115
|
+
// parse the file as JS. JSON is an expression, so a default export is the whole translation.
|
|
116
|
+
files[e.path] = `export default ${e.text?.trim() || "null"}`
|
|
111
117
|
} else {
|
|
112
118
|
files[e.path] = e.text ?? ""
|
|
113
119
|
}
|
|
114
120
|
}
|
|
121
|
+
return files
|
|
122
|
+
}
|
|
115
123
|
|
|
124
|
+
export const compileProjectWithMap = async (opts: CompileOptions): Promise<CompileResult> => {
|
|
125
|
+
// Client bundle: every *.server.ts becomes its RPC/channel stub (server code stays server-side).
|
|
126
|
+
const entries = stubServerEntries(opts.entries, opts.serverUrl ?? "")
|
|
127
|
+
const entry = opts.entryOverride ?? detectEntry(entries, scanModulesChisel).entry
|
|
128
|
+
if (!entry) {
|
|
129
|
+
throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const files = entriesToFiles(entries, opts)
|
|
116
133
|
const { code, map } = await bundleProjectWithMap(
|
|
117
134
|
entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true,
|
|
118
135
|
)
|
|
119
|
-
if (opts.header === false) {
|
|
120
|
-
return map ? `${code}\n${inlineSourceMapComment(map)}` : code
|
|
121
|
-
}
|
|
136
|
+
if (opts.header === false) return { code, map, entry }
|
|
122
137
|
const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader, icons: opts.iconsHeader })
|
|
123
|
-
|
|
124
|
-
// Shift the map down past the header (prepended before the code); the inline comment lands last.
|
|
138
|
+
// Shift the map down past the header (prepended before the code).
|
|
125
139
|
const headerLines = (header.match(/\n/g) ?? []).length
|
|
126
|
-
return
|
|
140
|
+
return { code: header + code, map: map ? offsetSourceMap(map, headerLines) : null, entry }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The bundle as ONE string, the map inlined at its end (the shipped/preview form). */
|
|
144
|
+
export const compileProject = async (opts: CompileOptions): Promise<string> => {
|
|
145
|
+
const { code, map } = await compileProjectWithMap(opts)
|
|
146
|
+
return map ? `${code}\n${inlineSourceMapComment(map)}` : code
|
|
127
147
|
}
|
package/src/compile/index.ts
CHANGED
|
@@ -63,9 +63,12 @@ export { offsetSourceMap, inlineSourceMapComment } from "./sourcemap"
|
|
|
63
63
|
export { buildHeader, detectProjectKind, type HeaderOptions } from "./header"
|
|
64
64
|
export {
|
|
65
65
|
compileProject,
|
|
66
|
+
compileProjectWithMap,
|
|
67
|
+
entriesToFiles,
|
|
66
68
|
type CompileEntry,
|
|
67
69
|
type CompileOptions,
|
|
68
70
|
type CompileFileType,
|
|
71
|
+
type CompileResult,
|
|
69
72
|
} from "./compileProject"
|
|
70
73
|
export {
|
|
71
74
|
loadSceneHarness,
|
package/src/core/Aspect.ts
CHANGED
|
@@ -96,14 +96,27 @@ type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
|
|
|
96
96
|
type FixedInstaller = (fixed: PhaseFn) => boolean
|
|
97
97
|
let frameInstaller: FrameInstaller | undefined
|
|
98
98
|
let fixedInstaller: FixedInstaller | undefined
|
|
99
|
-
/** @internal Install a render-synced frame source for aspect update(dt) (+ the fixed-step source). Called from an engine layer.
|
|
100
|
-
|
|
99
|
+
/** @internal Install a render-synced frame source for aspect update(dt) (+ the fixed-step source). Called from an engine layer.
|
|
100
|
+
* The FIRST engine layer wins: a 3D game that later builds a Scene2D for a UIImage minimap keeps its dispatch on the
|
|
101
|
+
* 3D engine (the one it presents); the 2D engine's phases stay unwired there, so nothing ticks twice. */
|
|
102
|
+
export const _installAspectFrames = (fn: FrameInstaller, fixed?: FixedInstaller): void => {
|
|
103
|
+
if (frameInstaller) return
|
|
104
|
+
frameInstaller = fn; fixedInstaller = fixed
|
|
105
|
+
}
|
|
101
106
|
|
|
102
107
|
/** @internal Engine-level phase hooks (the Net system, sdk/src/net): FIRST in the early and fixed
|
|
103
108
|
* phases, LAST in the late one, ahead of / behind every game aspect regardless of ordering
|
|
104
109
|
* constraints. Single slot each; raw dt. */
|
|
105
110
|
export const _phaseHooks: { early: PhaseFn | null; fixed: PhaseFn | null; late: PhaseFn | null } = { early: null, fixed: null, late: null }
|
|
106
111
|
|
|
112
|
+
/** @internal Advances at the start of EVERY phase pass — early, each fixed step, late — on the
|
|
113
|
+
* render-synced path and the setLoop fallback alike. The key for "read the engine once per phase"
|
|
114
|
+
* caches (gl/Vehicle's state block): between two passes the engine rewrites state natively (the
|
|
115
|
+
* physics steps, the transform sync), so a value cached under an older stamp is stale. Once per
|
|
116
|
+
* FRAME is not enough — the early and late phases of one frame straddle the physics step, and a
|
|
117
|
+
* value first read in `updateBefore` must not be what `update` sees. */
|
|
118
|
+
export const _phaseStamp = { value: 0 }
|
|
119
|
+
|
|
107
120
|
let attachSeq = 0
|
|
108
121
|
const warned = new Set<string>()
|
|
109
122
|
const warnOnce = (msg: string): void => { if (!warned.has(msg)) { warned.add(msg); console.warn(msg) } }
|
|
@@ -213,8 +226,12 @@ let fixedFallbackAcc = 0
|
|
|
213
226
|
// The FIXED phase: dt = Time.fixedDt (1/60) every call, once per physics substep, on every host. The
|
|
214
227
|
// engine drives it from inside its substep loop when it has the hook; otherwise (older wasm / Apple /
|
|
215
228
|
// Android builds, the setLoop path) the SDK steps its own accumulator right after the early phase -
|
|
216
|
-
// same step, same 4-substep cap, same drop-the-backlog rule as creator-
|
|
217
|
-
const fixedStep: PhaseFn = (dt) => {
|
|
229
|
+
// same step, same 4-substep cap, same drop-the-backlog rule as creator-physics' cphysFrameBegin.
|
|
230
|
+
const fixedStep: PhaseFn = (dt) => {
|
|
231
|
+
_phaseStamp.value++ // a substep moved the bodies natively since the last pass
|
|
232
|
+
_phaseHooks.fixed?.(dt)
|
|
233
|
+
fixedUpdaters.run("updateFixed", dt)
|
|
234
|
+
}
|
|
218
235
|
const fixedFallback = (): void => {
|
|
219
236
|
const step = Time.fixedDt
|
|
220
237
|
fixedFallbackAcc += Time.dt // scaled: paused -> no steps, slow-motion -> fewer steps, never a shorter dt
|
|
@@ -229,12 +246,17 @@ const ensureDispatch = (): void => {
|
|
|
229
246
|
// Both phases scale their OWN raw dt (the engine hands the same value to both; a host or test that
|
|
230
247
|
// drives them apart still gets each phase's dt right). The frame counter / clocks advance once, early.
|
|
231
248
|
const early: PhaseFn = (dt) => {
|
|
249
|
+
_phaseStamp.value++
|
|
232
250
|
Time._beginFrame(dt)
|
|
233
251
|
_phaseHooks.early?.(dt)
|
|
234
252
|
earlyUpdaters.run("updateBefore", Time.dt)
|
|
235
253
|
if (fixedFallbackOn) fixedFallback()
|
|
236
254
|
}
|
|
237
|
-
const late: PhaseFn = (dt) => {
|
|
255
|
+
const late: PhaseFn = (dt) => {
|
|
256
|
+
_phaseStamp.value++ // the physics step + transform sync ran since the early pass
|
|
257
|
+
lateUpdaters.run("update", Time._phaseDt(dt))
|
|
258
|
+
_phaseHooks.late?.(dt)
|
|
259
|
+
}
|
|
238
260
|
// render-synced source if an engine layer installed one; else one host loop (early then late).
|
|
239
261
|
if (!(frameInstaller && frameInstaller(early, late))) {
|
|
240
262
|
setLoop((dt) => { early(dt); late(dt) })
|
|
@@ -266,10 +288,13 @@ const registerUpdater = (inst: Aspect<any, any, any>): void => {
|
|
|
266
288
|
}
|
|
267
289
|
const unregisterUpdater = (inst: Aspect<any, any, any>): void => {
|
|
268
290
|
const a = internals(inst)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
291
|
+
const phases = a._phases
|
|
292
|
+
if (phases === 0) return
|
|
293
|
+
// `_phases` records which lists the aspect is in (1 early / 2 late / 4 fixed) — only those are
|
|
294
|
+
// scanned, so detaching a late-only aspect never walks the early or fixed lists.
|
|
295
|
+
if (phases & 1) earlyUpdaters.remove(a)
|
|
296
|
+
if (phases & 2) lateUpdaters.remove(a)
|
|
297
|
+
if (phases & 4) fixedUpdaters.remove(a)
|
|
273
298
|
a._phases = 0
|
|
274
299
|
}
|
|
275
300
|
|
package/src/g2/Scene2D.ts
CHANGED
|
@@ -167,6 +167,13 @@ export class Scene2D implements Presentable {
|
|
|
167
167
|
return this
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/** @internal UIImage source marker (UIImage.toSrc): `UIImage(scene)` shows this scene live — the
|
|
171
|
+
* host draws it into the image node's box every frame with the scene's own camera (a minimap, a
|
|
172
|
+
* portrait, any 2D view inside the UI). Needs no open(); the scene keeps its aspects and animations. */
|
|
173
|
+
_imageSrc(): { scene2d: number } {
|
|
174
|
+
return { scene2d: this.id }
|
|
175
|
+
}
|
|
176
|
+
|
|
170
177
|
/** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
|
|
171
178
|
_viewDesc(): object {
|
|
172
179
|
return this._vd ??= { type: "scene2d", sceneId: this.id, _p: this }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// A 3D sound emitter, as an aspect on a Node (docs/audio-plan.md §2). The engine follows the node's
|
|
2
|
+
// world pose every frame (position, facing for the cone, velocity for doppler); voices played
|
|
3
|
+
// through it are spatialized against the scene's listener (scene.audio.listener, the camera by
|
|
4
|
+
// default). Parented, animated and physics-driven nodes just work.
|
|
5
|
+
//
|
|
6
|
+
// const gun = new Node().aspect(AudioSource, { minDistance: 1, maxDistance: 40 })
|
|
7
|
+
// gun.audio.play(shot, { pitch: 0.95 + Math.random() * 0.1 })
|
|
8
|
+
// const hum = car.audio.play(engineLoop, { loop: true }); hum.pitch = 0.8 + rpm * 0.6
|
|
9
|
+
// speaker.aspect(AudioSource, { cone: { inner: 60, outer: 120, outerGain: 0.3 } }) // facing −Z
|
|
10
|
+
// enemy.aspect(AudioSource, { occlusion: true }) // a wall between it and the listener muffles it
|
|
11
|
+
//
|
|
12
|
+
// Without host audio the aspect is inert: `play` returns an inert Voice, nothing throws.
|
|
13
|
+
|
|
14
|
+
import { Aspect } from "../core/Aspect"
|
|
15
|
+
import type { FieldMeta } from "../core/fields"
|
|
16
|
+
import { audio, sourceParams, type PlaySoundOptions, type Rolloff } from "../audio/audio"
|
|
17
|
+
import type { Sound } from "../audio/Sound"
|
|
18
|
+
import { audioSupported } from "../audio/support"
|
|
19
|
+
import type { Voice } from "../audio/Voice"
|
|
20
|
+
import type { Node } from "./Node"
|
|
21
|
+
|
|
22
|
+
export type AudioCone = {
|
|
23
|
+
/** Degrees of full volume around the node's −Z. */
|
|
24
|
+
inner: number
|
|
25
|
+
/** Degrees where the volume has fallen to `outerGain`. */
|
|
26
|
+
outer: number
|
|
27
|
+
/** Volume behind the source, 0 … 1. */
|
|
28
|
+
outerGain?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class AudioSource extends Aspect<"audio", Node> {
|
|
32
|
+
static readonly aspect = "audio"
|
|
33
|
+
|
|
34
|
+
static fields: FieldMeta<AudioSource> = {
|
|
35
|
+
minDistance: { min: 0.05, max: 100, step: 0.05 },
|
|
36
|
+
maxDistance: { min: 0.5, max: 1000, step: 0.5 },
|
|
37
|
+
doppler: { min: 0, max: 1, step: 0.05 },
|
|
38
|
+
spread: { min: 0, max: 1, step: 0.05 },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private _minDistance = 1
|
|
42
|
+
private _maxDistance = 50
|
|
43
|
+
private _rolloff: Rolloff = "inverse"
|
|
44
|
+
private _cone: AudioCone | null = null
|
|
45
|
+
private _doppler = 0
|
|
46
|
+
private _spread = 0
|
|
47
|
+
private _occlusion = false
|
|
48
|
+
private _bus = "sfx"
|
|
49
|
+
/** @internal engine source id (0 = not attached / no audio). */
|
|
50
|
+
_id = 0
|
|
51
|
+
|
|
52
|
+
/** Metres of full volume around the node. Default 1. */
|
|
53
|
+
get minDistance(): number { return this._minDistance }
|
|
54
|
+
set minDistance(v: number) { this._minDistance = v; this._push() }
|
|
55
|
+
/** Metres beyond which the source no longer gets quieter. Default 50. */
|
|
56
|
+
get maxDistance(): number { return this._maxDistance }
|
|
57
|
+
set maxDistance(v: number) { this._maxDistance = v; this._push() }
|
|
58
|
+
/** `inverse` (default), `linear`, `exponential`, `none`. */
|
|
59
|
+
get rolloff(): Rolloff { return this._rolloff }
|
|
60
|
+
set rolloff(v: Rolloff) { this._rolloff = v; this._push() }
|
|
61
|
+
/** Directional source along the node's −Z; null = omnidirectional (default). */
|
|
62
|
+
get cone(): AudioCone | null { return this._cone }
|
|
63
|
+
set cone(v: AudioCone | null) { this._cone = v; this._push() }
|
|
64
|
+
/** Doppler amount 0 … 1 (0 = off, the default) — needs the node to actually move. */
|
|
65
|
+
get doppler(): number { return this._doppler }
|
|
66
|
+
set doppler(v: number) { this._doppler = v; this._push() }
|
|
67
|
+
/** 0 = pin-point panning (default) … 1 = the same on every speaker (a big, close source). */
|
|
68
|
+
get spread(): number { return this._spread }
|
|
69
|
+
set spread(v: number) { this._spread = v; this._push() }
|
|
70
|
+
/** The engine raycasts listener → source (against solid bodies) and muffles the voices when
|
|
71
|
+
* something is in the way. Default false. */
|
|
72
|
+
get occlusion(): boolean { return this._occlusion }
|
|
73
|
+
set occlusion(v: boolean) { this._occlusion = v; this._push() }
|
|
74
|
+
/** Default bus for voices on this source. Default `sfx`. */
|
|
75
|
+
get bus(): string { return this._bus }
|
|
76
|
+
set bus(v: string) { this._bus = v; this._push() }
|
|
77
|
+
|
|
78
|
+
/** Live voices on this source. */
|
|
79
|
+
get voices(): number { return this._id && audioSupported ? _creatorAudio.sourceVoices(this._id) : 0 }
|
|
80
|
+
|
|
81
|
+
onAttach(): void {
|
|
82
|
+
if (!audioSupported) return
|
|
83
|
+
this._id = _creatorAudio.createSource()
|
|
84
|
+
if (!this._id) return
|
|
85
|
+
_creatorAudio.attachSource(this._id, this.node.id)
|
|
86
|
+
this._push()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
onDetach(): void {
|
|
90
|
+
if (this._id && audioSupported) _creatorAudio.destroySource(this._id)
|
|
91
|
+
this._id = 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Play a clip from this node. */
|
|
95
|
+
play(sound: Sound, options: PlaySoundOptions = {}): Voice {
|
|
96
|
+
return audio._play(sound, this._id, options)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Stop every voice on this source (fade in seconds). */
|
|
100
|
+
stopAll(fade = 0): void {
|
|
101
|
+
if (this._id && audioSupported) _creatorAudio.stopSource(this._id, fade)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private _push(): void {
|
|
105
|
+
if (!this._id || !audioSupported) return
|
|
106
|
+
const cone = this._cone
|
|
107
|
+
_creatorAudio.setSourceParams(this._id, sourceParams({
|
|
108
|
+
minDistance: this._minDistance, maxDistance: this._maxDistance, rolloff: this._rolloff,
|
|
109
|
+
coneInner: cone ? cone.inner : 360, coneOuter: cone ? cone.outer : 360, coneOuterGain: cone ? cone.outerGain ?? 0.25 : 1,
|
|
110
|
+
doppler: this._doppler, spread: this._spread, occlusion: this._occlusion, bus: audio._busId(this._bus),
|
|
111
|
+
}))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A reverb zone, as an aspect on a Node (docs/audio-plan.md §2): a box or sphere volume that
|
|
2
|
+
// follows the node; while the listener is inside, the zone's reverb blends onto a bus (the bus's
|
|
3
|
+
// own reverb outside it). Zones nest: the smallest one containing the listener wins. `blend` is
|
|
4
|
+
// how many metres inside the border the crossfade takes — walking through a doorway is smooth.
|
|
5
|
+
//
|
|
6
|
+
// new Node().aspect(AudioZone, { box: [6, 2.5, 6], reverb: 'hall', blend: 2 })
|
|
7
|
+
// corridor.aspect(Shape, { box: [1, 1.5, 8] }).aspect(AudioZone, { reverb: 'bathroom' }) // the Shape's box
|
|
8
|
+
//
|
|
9
|
+
// Sizes follow Shape's convention: `box` = HALF-extents, `sphere` = radius, in the node's local
|
|
10
|
+
// units (the node's scale applies). Without host audio the aspect is inert.
|
|
11
|
+
|
|
12
|
+
import { Aspect } from "../core/Aspect"
|
|
13
|
+
import { reverbBlock, type ReverbParams, type ReverbPreset } from "../audio/Bus"
|
|
14
|
+
import { audio } from "../audio/audio"
|
|
15
|
+
import { audioSupported } from "../audio/support"
|
|
16
|
+
import { cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
17
|
+
import type { Node } from "./Node"
|
|
18
|
+
import { Shape } from "./Shape"
|
|
19
|
+
|
|
20
|
+
export class AudioZone extends Aspect<"audioZone", Node> {
|
|
21
|
+
static readonly aspect = "audioZone"
|
|
22
|
+
|
|
23
|
+
private _box: Vec3Like | null = null
|
|
24
|
+
private _sphere: number | null = null
|
|
25
|
+
private _reverb: ReverbPreset | ReverbParams = "room"
|
|
26
|
+
private _blend = 1
|
|
27
|
+
private _bus = "sfx"
|
|
28
|
+
/** @internal engine zone id. */
|
|
29
|
+
_id = 0
|
|
30
|
+
/** The engine's zone id — what `audio.stats.listenerZone` reports while the listener is inside. */
|
|
31
|
+
get id(): number { return this._id }
|
|
32
|
+
|
|
33
|
+
/** Half-extents [hx, hy, hz]; falls back to the node's Shape box, then a 1 m cube. */
|
|
34
|
+
get box(): Vec3Like | null { return this._box }
|
|
35
|
+
set box(v: Vec3Like | null) { this._box = v; if (v) this._sphere = null; this._push() }
|
|
36
|
+
/** Radius; falls back to the node's Shape sphere. */
|
|
37
|
+
get sphere(): number | null { return this._sphere }
|
|
38
|
+
set sphere(v: number | null) { this._sphere = v; if (v !== null) this._box = null; this._push() }
|
|
39
|
+
/** A preset name or explicit params (see Bus.reverb). */
|
|
40
|
+
get reverb(): ReverbPreset | ReverbParams { return this._reverb }
|
|
41
|
+
set reverb(v: ReverbPreset | ReverbParams) { this._reverb = v; this._push() }
|
|
42
|
+
/** Crossfade depth in metres inside the border (0 = a hard edge). Default 1. */
|
|
43
|
+
get blend(): number { return this._blend }
|
|
44
|
+
set blend(v: number) { this._blend = v; this._push() }
|
|
45
|
+
/** The bus the zone's reverb rides on. Default `sfx`. */
|
|
46
|
+
get bus(): string { return this._bus }
|
|
47
|
+
set bus(v: string) { this._bus = v; this._push() }
|
|
48
|
+
|
|
49
|
+
onAttach(): void {
|
|
50
|
+
if (!audioSupported) return
|
|
51
|
+
this._id = _creatorAudio.createZone()
|
|
52
|
+
if (!this._id) return
|
|
53
|
+
_creatorAudio.attachZone(this._id, this.node.id)
|
|
54
|
+
this._push()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onDetach(): void {
|
|
58
|
+
if (this._id && audioSupported) _creatorAudio.destroyZone(this._id)
|
|
59
|
+
this._id = 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private _push(): void {
|
|
63
|
+
if (!this._id || !audioSupported) return
|
|
64
|
+
let box = this._box, sphere = this._sphere
|
|
65
|
+
if (!box && sphere === null) {
|
|
66
|
+
const shape = this.node.get(Shape)
|
|
67
|
+
if (shape?.sphere !== undefined) sphere = shape.sphere
|
|
68
|
+
else if (shape?.box) box = shape.box
|
|
69
|
+
}
|
|
70
|
+
const dims = sphere !== null
|
|
71
|
+
? new Float32Array([ sphere * 2, sphere * 2, sphere * 2 ])
|
|
72
|
+
: new Float32Array(box ? [ cx(box) * 2, cy(box) * 2, cz(box) * 2 ] : [ 2, 2, 2 ])
|
|
73
|
+
_creatorAudio.setZone(this._id, sphere !== null ? 1 : 0, dims, this._blend, audio._busId(this._bus), reverbBlock(this._reverb))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Projected decals — bullet holes, footprints, dirt, blood. A DecalSet is one node that owns up to
|
|
2
|
+
// `max` decals drawn as ONE native renderable: each decal is a box that projects its image onto the
|
|
3
|
+
// opaque scene inside it (the engine reads the scene depth — creator-gl src/decals.cpp +
|
|
4
|
+
// materials/src/decal.mat), so it wraps floor-wall corners, stairs and curved surfaces without any
|
|
5
|
+
// geometry access. It lands on opaque surfaces only: never on particles, glass or other decals.
|
|
6
|
+
//
|
|
7
|
+
// Coordinates are in the SET's space: a set added at the scene root with no transform takes world
|
|
8
|
+
// coordinates (the usual case); a set parented to a door rides with it and takes door-local ones.
|
|
9
|
+
// Slots are a ring — a full set recycles its oldest decal, so `max` is the on-screen budget.
|
|
10
|
+
|
|
11
|
+
import { Node } from "./Node"
|
|
12
|
+
import { Material } from "./Material"
|
|
13
|
+
import type { Texture } from "./Texture"
|
|
14
|
+
import { Vec3, type Vec3Like } from "../math/vec"
|
|
15
|
+
import { Quat, type QuatLike } from "../math/quat"
|
|
16
|
+
import { Color, type ColorInput } from "../core/color"
|
|
17
|
+
|
|
18
|
+
/** Per-decal look and life — defaults come from the set's options. */
|
|
19
|
+
export type DecalOptions = {
|
|
20
|
+
/** Image width and height on the surface (world units); a number = square. Default 0.2. */
|
|
21
|
+
size?: number | [number, number]
|
|
22
|
+
/** Projection depth (world units): how far in front of and behind the hit point the decal still
|
|
23
|
+
* lands. Default = the smaller of width / height. */
|
|
24
|
+
depth?: number
|
|
25
|
+
/** Atlas cell (row-major from the top-left) for a set with a `sheet`; `"random"` picks one.
|
|
26
|
+
* Default 0. */
|
|
27
|
+
frame?: number | "random"
|
|
28
|
+
/** Tint multiplied into the image. Default white. */
|
|
29
|
+
tint?: ColorInput
|
|
30
|
+
/** 0..1 on top of the tint's alpha. Default 1. */
|
|
31
|
+
opacity?: number
|
|
32
|
+
/** Seconds until the decal is gone; 0 = stays until recycled. Default 0. */
|
|
33
|
+
life?: number
|
|
34
|
+
/** Seconds to fade in after spawning. Default 0. */
|
|
35
|
+
fadeIn?: number
|
|
36
|
+
/** Seconds of fade at the end of `life` (ignored with life 0). Default 0. */
|
|
37
|
+
fadeOut?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type DecalSetOptions = DecalOptions & {
|
|
41
|
+
/** The material — `Material.decal({ map })` by default (`map` below is its shortcut). A custom
|
|
42
|
+
* material must keep decal.mat's vertex contract. */
|
|
43
|
+
material?: Material
|
|
44
|
+
/** Atlas texture for the default material. */
|
|
45
|
+
map?: Texture
|
|
46
|
+
/** Tangent-space normal atlas (same cell grid) → a RELIEF decal that bends the surface's
|
|
47
|
+
* lighting instead of painting a colour (`Material.decal` `normalMap`). Footprints and dents
|
|
48
|
+
* need only this; a bullet hole gives `map` too and its colour multiplies in. */
|
|
49
|
+
normalMap?: Texture
|
|
50
|
+
/** Relief strength for `normalMap`. Default 1. Default material only. */
|
|
51
|
+
bump?: number
|
|
52
|
+
/** Atlas grid of the map: columns, or [columns, rows]. Default 1 (the whole texture). */
|
|
53
|
+
sheet?: number | [number, number]
|
|
54
|
+
/** Slot budget — the most decals alive at once. Default 256. */
|
|
55
|
+
max?: number
|
|
56
|
+
/** Soft fraction (0..1) of the box's half depth at both ends, so an oblique surface leaves the
|
|
57
|
+
* box gently. Default 0.3. Default material only. */
|
|
58
|
+
edge?: number
|
|
59
|
+
/** Surfaces turned more than this away from the projection axis fade out — the cosine of the
|
|
60
|
+
* angle (0.3 ≈ 72°) keeps a floor hit off the wall it meets; 0 = project onto anything.
|
|
61
|
+
* Default 0.3. Default material only. */
|
|
62
|
+
angleFade?: number
|
|
63
|
+
/** HDR boost of the image (0 = none). Default material only. */
|
|
64
|
+
emissive?: number
|
|
65
|
+
name?: string
|
|
66
|
+
/** Coarse draw order, 0 (first) … 7 (last); default 4 — see `Mesh.renderPriority`. Decals are
|
|
67
|
+
* blended and sort with the other blended draws of their priority. */
|
|
68
|
+
renderPriority?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `spawn` placement: where the image sits on the surface. */
|
|
72
|
+
export type DecalSpawnOptions = DecalOptions & {
|
|
73
|
+
/** World direction the image's top points along the surface (projected onto it) — a footprint's
|
|
74
|
+
* travel direction. Default: a random spin. */
|
|
75
|
+
up?: Vec3Like
|
|
76
|
+
/** Extra spin around the normal, radians. Default: random when `up` is not given, else 0. */
|
|
77
|
+
rotation?: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `place` / `update` placement: a full frame, like a node looking INTO the surface (its −Z is the
|
|
81
|
+
* projection direction, +Y the image's top). */
|
|
82
|
+
export type DecalPlacement = DecalOptions & {
|
|
83
|
+
position: Vec3Like
|
|
84
|
+
/** A quaternion, or Euler degrees (YXZ) like `Node.eulerAngles`. Default identity = projecting
|
|
85
|
+
* down −Z. */
|
|
86
|
+
rotation?: QuatLike | Vec3Like
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const RECORD = 23
|
|
90
|
+
const NO_SLOT = 0xFFFFFFFF
|
|
91
|
+
|
|
92
|
+
export class DecalSet extends Node {
|
|
93
|
+
private _material: Material
|
|
94
|
+
private readonly _max: number
|
|
95
|
+
private readonly _cols: number
|
|
96
|
+
private readonly _rows: number
|
|
97
|
+
private readonly _defaults: DecalOptions
|
|
98
|
+
private readonly _rec = new Float32Array(RECORD)
|
|
99
|
+
private readonly _slots = new Map<number, DecalPlacement>()
|
|
100
|
+
private static _warned = false
|
|
101
|
+
|
|
102
|
+
constructor(options: DecalSetOptions = {}) {
|
|
103
|
+
super()
|
|
104
|
+
if (options.name) this.name = options.name
|
|
105
|
+
this._material = options.material ?? Material.decal({
|
|
106
|
+
map: options.map, normalMap: options.normalMap, bump: options.bump,
|
|
107
|
+
edge: options.edge, angleFade: options.angleFade, emissive: options.emissive,
|
|
108
|
+
})
|
|
109
|
+
this._max = Math.max(1, Math.round(options.max ?? 256))
|
|
110
|
+
const sheet = options.sheet ?? 1
|
|
111
|
+
this._cols = Math.max(1, Array.isArray(sheet) ? sheet[0] : sheet)
|
|
112
|
+
this._rows = Math.max(1, Array.isArray(sheet) ? sheet[1] : 1)
|
|
113
|
+
this._defaults = {
|
|
114
|
+
size: options.size ?? 0.2, depth: options.depth, frame: options.frame ?? 0, tint: options.tint,
|
|
115
|
+
opacity: options.opacity ?? 1, life: options.life ?? 0, fadeIn: options.fadeIn ?? 0, fadeOut: options.fadeOut ?? 0,
|
|
116
|
+
}
|
|
117
|
+
if (_creator.createDecalSet) {
|
|
118
|
+
_creator.createDecalSet(this.id, Material.idOf(this._material), this._max)
|
|
119
|
+
if (options.renderPriority !== undefined) this.renderPriority = options.renderPriority
|
|
120
|
+
} else if (!DecalSet._warned) {
|
|
121
|
+
DecalSet._warned = true
|
|
122
|
+
console.warn("[decals] this host has no createDecalSet — decals are not drawn")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get material(): Material { return this._material }
|
|
127
|
+
/** Decals alive right now. */
|
|
128
|
+
get count(): number { return _creator.decalCount ? _creator.decalCount(this.id) : 0 }
|
|
129
|
+
/** The slot budget the set was created with. */
|
|
130
|
+
get max(): number { return this._max }
|
|
131
|
+
|
|
132
|
+
/** Coarse draw order, 0 … 7 — see `Mesh.renderPriority`. Write-only. */
|
|
133
|
+
set renderPriority(v: number) {
|
|
134
|
+
if (_creator.setRenderPriority) _creator.setRenderPriority(this.id, Math.max(0, Math.min(7, Math.round(v))))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Stamp a decal on a surface: `point` on it, `normal` out of it (a raycast hit, a foot plant
|
|
138
|
+
* with `Vec3.up`). Returns the slot for `update` / `remove` (−1 when the host draws none). */
|
|
139
|
+
spawn(point: Vec3Like, normal: Vec3Like, options: DecalSpawnOptions = {}): number {
|
|
140
|
+
const n = new Vec3(normal).normalize()
|
|
141
|
+
// Image up on the surface: the hint projected onto the plane, or any perpendicular.
|
|
142
|
+
let up = options.up ? new Vec3(options.up) : null
|
|
143
|
+
if (up) { up = up.sub(n.scale(up.dot(n))); if (up.length() < 1e-4) up = null }
|
|
144
|
+
if (!up) {
|
|
145
|
+
const helper = Math.abs(n.y) < 0.99 ? Vec3.up : Vec3.right
|
|
146
|
+
up = helper.sub(n.scale(helper.dot(n)))
|
|
147
|
+
}
|
|
148
|
+
up = up.normalize()
|
|
149
|
+
// right = up × normal keeps the frame right-handed (right × up = normal), so the image reads
|
|
150
|
+
// unmirrored from the normal's side; then the spin around the normal.
|
|
151
|
+
let right = up.cross(n)
|
|
152
|
+
const spin = options.rotation ?? (options.up ? 0 : Math.random() * Math.PI * 2)
|
|
153
|
+
if (spin !== 0) {
|
|
154
|
+
const c = Math.cos(spin), s = Math.sin(spin)
|
|
155
|
+
const r2 = right.scale(c).add(up.scale(s))
|
|
156
|
+
up = up.scale(c).sub(right.scale(s))
|
|
157
|
+
right = r2
|
|
158
|
+
}
|
|
159
|
+
return this._write(NO_SLOT, right, up, n, new Vec3(point), options)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Place a decal by a full frame (an editor-placed stain, a moving marker). Returns the slot. */
|
|
163
|
+
place(placement: DecalPlacement): number {
|
|
164
|
+
const slot = this._writePlacement(NO_SLOT, placement)
|
|
165
|
+
if (slot >= 0) this._slots.set(slot, placement)
|
|
166
|
+
return slot
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Move / restyle a placed decal; fields left out keep the values it was placed with. Its birth
|
|
170
|
+
* time (the life clock) is kept. */
|
|
171
|
+
update(slot: number, placement: Partial<DecalPlacement>): this {
|
|
172
|
+
const prev = this._slots.get(slot)
|
|
173
|
+
if (!prev) return this
|
|
174
|
+
const next = { ...prev, ...placement }
|
|
175
|
+
this._slots.set(slot, next)
|
|
176
|
+
this._writePlacement(slot, next)
|
|
177
|
+
return this
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
remove(slot: number): this {
|
|
181
|
+
this._slots.delete(slot)
|
|
182
|
+
if (_creator.removeDecal && slot >= 0) _creator.removeDecal(this.id, slot)
|
|
183
|
+
return this
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
clear(): this {
|
|
187
|
+
this._slots.clear()
|
|
188
|
+
if (_creator.clearDecals) _creator.clearDecals(this.id)
|
|
189
|
+
return this
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private _writePlacement(slot: number, p: DecalPlacement): number {
|
|
193
|
+
let q: Quat
|
|
194
|
+
if (p.rotation === undefined) q = Quat.identity
|
|
195
|
+
else if (p.rotation instanceof Quat || (p.rotation as any).w !== undefined || (p.rotation as any).length === 4) q = new Quat(p.rotation as QuatLike)
|
|
196
|
+
else { const e = new Vec3(p.rotation as Vec3Like); q = Quat.fromEuler(e.x, e.y, e.z) }
|
|
197
|
+
const right = q.rotateVec3(Vec3.right)
|
|
198
|
+
const up = q.rotateVec3(Vec3.up)
|
|
199
|
+
const normal = right.cross(up) // +Z of the frame: the node's −Z looks into the surface
|
|
200
|
+
return this._write(slot, right, up, normal, new Vec3(p.position), p)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private _write(slot: number, right: Vec3, up: Vec3, normal: Vec3, centre: Vec3, o: DecalOptions): number {
|
|
204
|
+
const d = this._defaults
|
|
205
|
+
const size = o.size ?? d.size!
|
|
206
|
+
const w = typeof size === "number" ? size : size[0]
|
|
207
|
+
const h = typeof size === "number" ? size : size[1]
|
|
208
|
+
const depth = o.depth ?? d.depth ?? Math.min(w, h)
|
|
209
|
+
const r = this._rec
|
|
210
|
+
r[0] = right.x * w; r[1] = right.y * w; r[2] = right.z * w
|
|
211
|
+
r[3] = up.x * h; r[4] = up.y * h; r[5] = up.z * h
|
|
212
|
+
r[6] = normal.x * depth; r[7] = normal.y * depth; r[8] = normal.z * depth
|
|
213
|
+
r[9] = centre.x; r[10] = centre.y; r[11] = centre.z
|
|
214
|
+
// atlas cell → rect (v grows downward: v0 is the cell's top)
|
|
215
|
+
const cells = this._cols * this._rows
|
|
216
|
+
const frameOpt = o.frame ?? d.frame!
|
|
217
|
+
const frame = frameOpt === "random" ? Math.floor(Math.random() * cells) : ((Math.floor(frameOpt) % cells) + cells) % cells
|
|
218
|
+
const col = frame % this._cols, row = Math.floor(frame / this._cols)
|
|
219
|
+
r[12] = col / this._cols; r[13] = row / this._rows; r[14] = (col + 1) / this._cols; r[15] = (row + 1) / this._rows
|
|
220
|
+
const tint = o.tint ?? d.tint
|
|
221
|
+
const [tr, tg, tb, ta] = tint === undefined ? [1, 1, 1, 1] : Color.toRgba01(tint)
|
|
222
|
+
const opacity = o.opacity ?? d.opacity!
|
|
223
|
+
r[16] = tr; r[17] = tg; r[18] = tb; r[19] = ta * opacity
|
|
224
|
+
r[20] = o.life ?? d.life!; r[21] = o.fadeIn ?? d.fadeIn!; r[22] = o.fadeOut ?? d.fadeOut!
|
|
225
|
+
if (slot === NO_SLOT) {
|
|
226
|
+
if (!_creator.addDecal) return -1
|
|
227
|
+
const s = _creator.addDecal(this.id, r)
|
|
228
|
+
return s === NO_SLOT ? -1 : s
|
|
229
|
+
}
|
|
230
|
+
if (_creator.updateDecal) _creator.updateDecal(this.id, slot, r)
|
|
231
|
+
return slot
|
|
232
|
+
}
|
|
233
|
+
}
|
package/src/gl/Geometry.ts
CHANGED
|
@@ -25,6 +25,11 @@ export class Geometry {
|
|
|
25
25
|
* tiles per face, which would fold every face onto the same texels); absent = the host reuses
|
|
26
26
|
* `uv`, which is right for a plane. */
|
|
27
27
|
uv1?: Float32Array
|
|
28
|
+
/** Per-vertex COLOURS: 4 bytes (r, g, b, a) per vertex, 0..255. A material that declares
|
|
29
|
+
* `requires: [color]` reads them through `getColor()` — one mesh, many colours, no material per
|
|
30
|
+
* shade (a debug drawer, a gradient along a curve). Absent = every vertex white, which is what
|
|
31
|
+
* the built-in materials expect. */
|
|
32
|
+
colors?: Uint8Array
|
|
28
33
|
/** @internal native mesh-type enum (0 triangles / 1 edges / 2 vertices). */
|
|
29
34
|
_meshKind = 0
|
|
30
35
|
|