@threenative/core 0.3.0 → 0.3.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.
Files changed (39) hide show
  1. package/README.md +10 -0
  2. package/capabilities.json +1881 -131
  3. package/dist/assets-kyoF7JlJ.d.ts +103 -0
  4. package/dist/{audio-Dp2mXpD3.d.ts → audio-BFiGneTL.d.ts} +62 -0
  5. package/dist/canvas-layer-BLVijiUJ.d.ts +62 -0
  6. package/dist/{game-CYIaKhgl.d.ts → game-XGrTzapq.d.ts} +350 -164
  7. package/dist/gpu-readback-D2iRvoe9.d.ts +112 -0
  8. package/dist/hot.d.ts +5 -3
  9. package/dist/hot.js +5 -1
  10. package/dist/index.d.ts +813 -143
  11. package/dist/index.js +4548 -941
  12. package/dist/net.d.ts +65 -0
  13. package/dist/net.js +643 -0
  14. package/dist/playtest.d.ts +29 -5
  15. package/dist/playtest.js +181 -35
  16. package/dist/react.d.ts +4 -2
  17. package/dist/{canvas-layer-CtrZHgIh.d.ts → renderer-C6hqZpoG.d.ts} +237 -75
  18. package/dist/world.d.ts +203 -4
  19. package/dist/world.js +2536 -25
  20. package/gpl/LICENSE.GPL +117 -0
  21. package/gpl/convert.py +192 -0
  22. package/gpl/recipes/_common.py +169 -0
  23. package/gpl/recipes/bake_ao.py +111 -0
  24. package/gpl/recipes/decimate.py +64 -0
  25. package/gpl/recipes/retarget.py +131 -0
  26. package/gpl/recipes/unwrap.py +71 -0
  27. package/mcp/blender-server.mjs +632 -0
  28. package/mcp/blender.mjs +27 -0
  29. package/mcp/engine-server.mjs +271 -23
  30. package/mcp/engine.mjs +15 -7
  31. package/mcp/install.d.mts +37 -0
  32. package/mcp/install.mjs +94 -26
  33. package/mcp/servers.d.mts +34 -0
  34. package/mcp/servers.mjs +110 -9
  35. package/package.json +36 -8
  36. package/patches/three@0.185.1.patch +249 -14
  37. package/scripts/ensure-mcp.mjs +20 -13
  38. package/scripts/bundle-engine-mcp.mjs +0 -15
  39. package/scripts/generate-version.mjs +0 -13
@@ -1,57 +1,9 @@
1
- import { Texture, Vector2, Vector3, Object3D, Camera, Intersection, Scene as Scene$1 } from 'three';
2
- import { h as IFramePhaseSample, U as Viewport, I as IRendererLike, C as CanvasLayer, W as IRendererOptions, X as IViewportOptions, g as IFrameBudgetWindow, e as IFrameBudgetOptions } from './canvas-layer-CtrZHgIh.js';
1
+ import { I as IAssetLoader, a as IAssetLoaderOptions } from './assets-kyoF7JlJ.js';
2
+ import { h as IFramePhaseSample, i as IPipelineCensus, I as IRendererLike, a6 as IRendererOptions, g as IFrameBudgetWindow, e as IFrameBudgetOptions } from './renderer-C6hqZpoG.js';
3
+ import { Vector2, Vector3, Object3D, Camera, Intersection, Scene as Scene$1 } from 'three';
4
+ import { V as Viewport, C as CanvasLayer, I as IViewportOptions } from './canvas-layer-BLVijiUJ.js';
3
5
  import { StoreApi } from 'zustand/vanilla';
4
6
 
5
- interface IAssetLoaderOptions {
6
- readonly basePath?: string;
7
- /**
8
- * URL of the manifest written by the asset compile step (`public/assets.manifest.json`).
9
- * Defaults to `assets.manifest.json` resolved against `basePath`. A logical path is resolved
10
- * to its compiled output through it; a manifest that is absent — 404 or unfetchable — falls
11
- * back to loading every path verbatim, while one that is served malformed or with an unknown
12
- * version throws.
13
- */
14
- readonly manifest?: string;
15
- readonly model?: (url: string) => Promise<unknown>;
16
- readonly texture?: (url: string) => Promise<Texture>;
17
- readonly audio?: (url: string) => Promise<AudioBuffer>;
18
- /**
19
- * The live renderer (`IRendererLike.raw`), handed to `KTX2Loader.detectSupport()` exactly
20
- * once so compiled KTX2 textures transcode to a format this machine's GPU actually supports.
21
- * Required for games whose textures compile to `.ktx2`; without it such a load throws rather
22
- * than silently uploading decoded RGBA.
23
- */
24
- readonly renderer?: unknown;
25
- }
26
- /** The structural slice of three's `KTX2Loader` core depends on. */
27
- interface IKTX2LoaderLike {
28
- detectSupport(renderer: unknown): unknown;
29
- load(url: string, onLoad: (texture: Texture) => void, onProgress?: (event: ProgressEvent<EventTarget>) => void, onError?: (error: unknown) => void): unknown;
30
- setTranscoderPath(path: string): unknown;
31
- }
32
- /**
33
- * Present only when a renderer was handed to the asset loader. `ready` settles once support
34
- * detection ran; it rejects naming the renderer and platform when no compressed format is
35
- * supported, and `defineGame` awaits it during boot so such a target fails at construction.
36
- */
37
- interface ICompressedTextureSupport {
38
- /**
39
- * The one shared instance, also handed to `GLTFLoader.setKTX2Loader()` for models. Resolves
40
- * `undefined` when the renderer exposed no surface to probe (see `createKtx2Loader`).
41
- */
42
- readonly loader: Promise<IKTX2LoaderLike | undefined>;
43
- readonly ready: Promise<void>;
44
- }
45
- interface IAssetLoader {
46
- readonly compressedTextures?: ICompressedTextureSupport;
47
- model<T = unknown>(path: string): Promise<T>;
48
- texture(path: string): Promise<Texture>;
49
- audio(path: string): Promise<AudioBuffer>;
50
- release(kind: "audio" | "model" | "texture", path: string): boolean;
51
- clear(): void;
52
- }
53
- declare function createAssetLoader(options?: IAssetLoaderOptions): IAssetLoader;
54
-
55
7
  type ThreeNativeOrientation = "landscape" | "portrait" | "sensor";
56
8
  /** Which renderer draws a game's `src/ui/`. @see IThreeNativeConfig.ui */
57
9
  type ThreeNativeUiRenderer = "native" | "web";
@@ -78,8 +30,59 @@ interface IThreeNativeBootSplash {
78
30
  readonly backgroundColor?: string;
79
31
  readonly image?: string;
80
32
  }
33
+ /** What a clip is for, stated as a bound on where its energy sits, in the inspector's bands. */
34
+ interface IThreeNativeAudioSpectrum {
35
+ readonly band: "air" | "high" | "low" | "mid" | "sub";
36
+ /** Most of the clip's energy that may sit in the band. */
37
+ readonly maxPercent?: number;
38
+ /** Least of the clip's energy that must sit in the band. */
39
+ readonly minPercent?: number;
40
+ }
41
+ /** Loop conditioning for a clip that repeats forever; its seam is cross-faded, then asserted. */
42
+ interface IThreeNativeAudioLoop {
43
+ /** Equal-power cross-fade in milliseconds. `0` keeps the clip's own length and still asserts. */
44
+ readonly crossFadeMs?: number;
45
+ /** How far the splice may move to find a quiet seam, in milliseconds. */
46
+ readonly spliceToleranceMs?: number;
47
+ }
48
+ /**
49
+ * Per-glob audio declarations. Which clips loop, which are positional, and what a clip is for are
50
+ * facts only the game knows, so they are declared and never inferred from a filename.
51
+ */
52
+ interface IThreeNativeAudioOverride {
53
+ /** `"none"` ships these bytes as committed; measurement and a declared loop's assertion run. */
54
+ readonly conditioning?: "none";
55
+ /** First matching override wins; matched against the logical path, e.g. `"audio/bed.ogg"`. */
56
+ readonly glob: string;
57
+ readonly loop?: boolean | IThreeNativeAudioLoop;
58
+ readonly normalise?: "ceiling" | "peak";
59
+ /** Peak ceiling in dBFS, between -60 and 0. */
60
+ readonly peakDb?: number;
61
+ /** The clip plays from a place in the world, so it is downmixed to mono. */
62
+ readonly positional?: boolean;
63
+ /** Vorbis VBR quality, -1 to 10. */
64
+ readonly quality?: number;
65
+ /** The largest wrap-to-neighbourhood step ratio a declared loop may ship with, 1 to 5. */
66
+ readonly seamMaxRatio?: number;
67
+ readonly spectrum?: IThreeNativeAudioSpectrum;
68
+ }
69
+ /**
70
+ * Audio conditioning options for the asset compile step; `"none"` ships clips verbatim.
71
+ *
72
+ * `"ceiling"` normalisation only ever attenuates, keeping the game's relative mix; `"peak"` also
73
+ * lifts a quiet clip to the ceiling.
74
+ */
75
+ interface IThreeNativeAudioConfig {
76
+ readonly normalise?: "ceiling" | "peak";
77
+ readonly overrides?: readonly IThreeNativeAudioOverride[];
78
+ readonly peakDb?: number;
79
+ readonly quality?: number;
80
+ readonly seamMaxRatio?: number;
81
+ }
81
82
  /** Texture compression options for the asset compile step; `"none"` ships sources verbatim. */
82
83
  interface IThreeNativeTexturesConfig {
84
+ /** Integer at least 4; caps the longest edge, preserving aspect and 4x4 alignment; never upscales. */
85
+ readonly maxSize?: number;
83
86
  readonly overrides?: readonly {
84
87
  readonly codec: "etc1s" | "none" | "uastc";
85
88
  readonly glob: string;
@@ -109,6 +112,13 @@ interface IThreeNativeModelsConfig {
109
112
  readonly positionBits?: number;
110
113
  readonly uvBits?: number;
111
114
  };
115
+ /**
116
+ * Write each distinct embedded image once under `shared/images/` and reference it from every
117
+ * model that carries it. A marketplace pack whose eight pines all embed the same bark map then
118
+ * ships and encodes it once. Default true; false embeds and re-encodes duplicate images in
119
+ * each model. The served GLB references files beside it.
120
+ */
121
+ readonly sharedImages?: boolean;
112
122
  /**
113
123
  * Embedded-texture compression for images carried inside a `.glb`.
114
124
  *
@@ -193,9 +203,27 @@ interface IThreeNativeConfig {
193
203
  readonly title?: string;
194
204
  readonly width?: number;
195
205
  readonly height?: number;
206
+ /** Start maximized on desktop when `display.fullscreen` is false. */
207
+ readonly maximized?: boolean;
196
208
  readonly resizable?: boolean;
197
209
  };
198
210
  readonly assets?: {
211
+ /**
212
+ * Audio conditioning options, or `"none"` to ship every clip exactly as committed. Absent
213
+ * means conditioning runs with defaults. `"none"` still measures and still reports.
214
+ */
215
+ readonly audio?: "none" | IThreeNativeAudioConfig;
216
+ /**
217
+ * Byte ceilings: uncooked defaults to 64,000,000; total defaults to "none". Mobile
218
+ * targets without decoders are exempt from uncooked. A number sets uncooked; "none"
219
+ * disables both gates. Measurements still print when a gate is disabled.
220
+ */
221
+ readonly budget?: number | "none" | {
222
+ readonly uncooked?: number | "none";
223
+ readonly total?: number | "none";
224
+ };
225
+ /** Source-relative globs omitted from builds; excluded bytes are still reported. */
226
+ readonly exclude?: readonly string[];
199
227
  readonly models?: "none" | IThreeNativeModelsConfig;
200
228
  readonly output?: string;
201
229
  readonly source?: string;
@@ -222,6 +250,14 @@ interface IThreeNativeConfig {
222
250
  readonly resolutionScale?: number | "auto";
223
251
  /** Portable multisampling. Sampling and resolution are one pixel budget, not two. */
224
252
  readonly antialias?: boolean;
253
+ /**
254
+ * Portable alpha antialiasing: resolve alpha-tested cutout silhouettes — foliage, fences,
255
+ * hair — through the multisample coverage mask rather than a binary `discard`. On by default,
256
+ * costing no target and no pass; it buys the cutouts the samples `antialias` already pays
257
+ * for. Set false for a deliberately hard-edged look. It does nothing on a single-sampled
258
+ * surface, and says so in `TN_ALPHA_ANTIALIASING` rather than reporting itself applied.
259
+ */
260
+ readonly alphaAntialiasing?: boolean;
225
261
  /**
226
262
  * Android-only rendering overrides selected by the engine.
227
263
  *
@@ -233,6 +269,12 @@ interface IThreeNativeConfig {
233
269
  readonly android?: {
234
270
  readonly resolutionScale?: number | "auto";
235
271
  readonly antialias?: boolean;
272
+ /**
273
+ * `alphaAntialiasing` belongs here for the reason `antialias` does, one step on: a phone
274
+ * that bought sampling back must be able to spend it on cutouts, and one that gave sampling
275
+ * up has nothing to spend.
276
+ */
277
+ readonly alphaAntialiasing?: boolean;
236
278
  };
237
279
  };
238
280
  readonly ui?: {
@@ -300,8 +342,14 @@ interface IInputAction {
300
342
  readonly scroll?: boolean;
301
343
  /** Add the signed two-pointer distance change to `axis(name)`; moving apart is positive. */
302
344
  readonly pinch?: boolean;
303
- /** Add raw mouse movement since the last input tick to `vector(name)`. */
345
+ /**
346
+ * Add raw mouse movement since the last input tick to `vector(name)`. A click on the pointer
347
+ * target automatically asks for pointer capture when this binding is present; call
348
+ * `captureMouse()` explicitly when a game needs to start capture from another named gesture.
349
+ */
304
350
  readonly pointerRelative?: boolean;
351
+ /** Disable the automatic click capture for a relative binding that has its own gesture. */
352
+ readonly captureOnClick?: boolean;
305
353
  /** The +x direction of `vector(name)`. */
306
354
  readonly right?: readonly string[];
307
355
  /** The +y direction of `vector(name)`. */
@@ -370,7 +418,7 @@ declare class InputMap {
370
418
  * a third pointer is ignored and a changing pair starts a new gesture without a jump.
371
419
  */
372
420
  axis(name: string): number;
373
- /** Request pointer capture. Call this from a user gesture on the game surface. */
421
+ /** Request pointer capture explicitly from a named game gesture. Relative bindings request it on canvas click by default. */
374
422
  captureMouse(): void;
375
423
  /** Release pointer capture. A browser refusal remains an unhandled rejection. */
376
424
  releaseMouse(): void;
@@ -382,6 +430,12 @@ declare class InputMap {
382
430
  dispose(): void;
383
431
  }
384
432
 
433
+ type AfterPhysicsCallback = (dt: number) => void;
434
+ interface IAfterPhysicsContext {
435
+ readonly afterPhysics: (callback: AfterPhysicsCallback) => () => void;
436
+ }
437
+ /** Register work that reads transforms after every simulation step and before the frame renders. */
438
+ declare function afterPhysics(context: IAfterPhysicsContext, callback: AfterPhysicsCallback): () => void;
385
439
  interface IRenderPerformanceMetrics {
386
440
  readonly drawCalls?: number;
387
441
  readonly triangles?: number;
@@ -408,6 +462,7 @@ declare class Registry {
408
462
  #private;
409
463
  add<T extends object>(name: string, entity: T): T;
410
464
  get<T extends object = object>(name: string): T | undefined;
465
+ forEach(callback: (name: string, entity: object) => void): void;
411
466
  remove(name: string): void;
412
467
  queueFree(target: string | object): void;
413
468
  sweep(): void;
@@ -558,84 +613,6 @@ type GameStore<T extends Record<string, unknown>> = StoreApi<T> & {
558
613
  stop(): void;
559
614
  };
560
615
 
561
- declare abstract class Scene<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> {
562
- static readonly initialState: Record<string, unknown> | undefined;
563
- load(_ctx: ICtx<TState, TPhysics>): void | Promise<void>;
564
- enter(_ctx: ICtx<TState, TPhysics>): SceneEnterResult<TState, TPhysics>;
565
- exit(_ctx: ICtx<TState, TPhysics>): void;
566
- update(_ctx: ICtx<TState, TPhysics>, _dt: number): void;
567
- render(_ctx: ICtx<TState, TPhysics>): void;
568
- }
569
- type SceneConstructor<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> = new () => Scene<TState, TPhysics>;
570
- type SceneFrame<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> = (ctx: ICtx<TState, TPhysics>, dt: number) => void;
571
- type SceneEnterResult<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> = // biome-ignore lint/suspicious/noConfusingVoidType: void preserves existing Scene.enter overrides.
572
- void | SceneFrame<TState, TPhysics>;
573
- interface IStartupStatus {
574
- /**
575
- * True once first-use compilation has settled — earlier than `phase === "ready"`, which also
576
- * waits for a sustained in-budget frame window.
577
- *
578
- * This is the signal a game wants when something must not run during the launch. `phase` cannot
579
- * express it: it is binary, and on a software rasteriser the frame window can only ever expire
580
- * rather than be met, so a game gated on `phase` alone does nothing for tens of seconds there.
581
- * Measured as a chase route of length `0.000000` against a required `6`, because the scenario
582
- * ended before the window did.
583
- */
584
- readonly compileSettled: boolean;
585
- /**
586
- * `collapsing` until first-use work and a sustained in-budget frame window complete, `ready`
587
- * once the world is safe to show.
588
- */
589
- readonly phase: "observing" | "collapsing" | "ready";
590
- /** 0 while first-use work or the sustained frame window is pending, then 1. */
591
- readonly progress: number;
592
- /** Resolves after first-use work and the sustained frame window, so it is always awaitable. */
593
- whenReady(): Promise<void>;
594
- }
595
- interface ICtx<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> {
596
- readonly fps: number;
597
- readonly renderer: IRendererLike;
598
- readonly viewport: Viewport;
599
- readonly scene: Scene$1;
600
- readonly camera: Camera;
601
- readonly canvasLayer: CanvasLayer;
602
- readonly entities: Registry;
603
- /**
604
- * Adds a node to the scene and hands it straight back, with its own type intact.
605
- *
606
- * Generic rather than `Object3D` because a game writes `const sea = ctx.add(new SpectralOcean())`
607
- * and then calls a method on it. Erasing the type here makes every typed node in every scene need
608
- * a cast back to what it already was, and that cast is where a game stops noticing it is holding
609
- * something else.
610
- */
611
- readonly add: <T extends Object3D>(object: T) => T;
612
- readonly input: InputMap;
613
- readonly pointer: IPointerEvents3D;
614
- readonly assets: IAssetLoader;
615
- readonly after: (delay: number, callback: () => void) => ScheduleHandle;
616
- readonly every: (callback: (dt: number) => void) => ScheduleHandle;
617
- readonly state: GameStore<TState>;
618
- readonly tween: <T extends object>(target: T, properties: {
619
- [K in keyof T]?: number;
620
- }, duration: number, options?: ITweenOptions) => Promise<void>;
621
- readonly random: IRandom;
622
- readonly raycast: (options?: IRaycastOptions) => Intersection | undefined;
623
- readonly raycastAll: (options?: IRaycastOptions, target?: Intersection[]) => readonly Intersection[];
624
- /**
625
- * The framework's own startup work — what a loading screen waits on.
626
- *
627
- * A shader may compile the first time something using it is drawn, and the render projection may
628
- * do its first build in that same frame. Both costs are real and belong before the world is shown.
629
- *
630
- * Keeping the world hidden until `whenReady()` resolves does more than hide the mess: the
631
- * shaders that would have been compiled for geometry the projection then discards are never
632
- * compiled at all, so waiting is *faster* than not waiting.
633
- */
634
- readonly startup: IStartupStatus;
635
- readonly goto: (name: string) => Promise<void>;
636
- physics: TPhysics;
637
- }
638
-
639
616
  /**
640
617
  * Compiles a scene's pipelines before the first frame, in slices, yielding between them.
641
618
  *
@@ -661,16 +638,28 @@ interface ICtx<TState extends Record<string, unknown> = Record<string, unknown>,
661
638
  */
662
639
  /** One renderable's worth of progress, reported as the warm-up advances. */
663
640
  interface IWarmUpProgress {
664
- /** Distinct pipelines warmed so far. */
641
+ /** Renderable candidates handed to the renderer so far. */
665
642
  readonly done: number;
666
- /** Distinct pipelines the warm-up will build in total. Known before the first slice. */
643
+ /** Renderable candidates the warm-up will hand to the renderer in total. */
667
644
  readonly total: number;
668
645
  }
646
+ /** The result of the optional persistent warm-up hint. */
647
+ type WarmUpCacheStatus = "disabled" | "unavailable" | "miss" | "hit" | "stored";
648
+ interface IWarmUpCacheOptions {
649
+ /**
650
+ * A game-owned revision for the scene's materials, shaders and renderer settings.
651
+ *
652
+ * The value is a hint that the native driver's own pipeline cache survived a relaunch; it is
653
+ * not a serialized WebGPU pipeline. Change it whenever those inputs change. A missing or
654
+ * unavailable localStorage implementation never prevents the warm-up from running.
655
+ */
656
+ readonly key: string;
657
+ }
669
658
  interface IWarmUpOptions {
670
659
  /** Compute kernels to compile in the same bounded startup window as draw pipelines. */
671
660
  readonly computeNodes?: readonly unknown[];
672
661
  /**
673
- * Distinct pipelines compiled between yields. Default 24.
662
+ * Renderable candidates handed to the renderer between yields. Default 24.
674
663
  *
675
664
  * The trade is presented frames against total warm-up time: every yield costs one frame's
676
665
  * present, and a slice of one would spend more time presenting than compiling. 24 puts a frame
@@ -705,18 +694,47 @@ interface IWarmUpOptions {
705
694
  * for and the only one measured to be affordable: on a Pixel 8 the renderer builds all 107 of
706
695
  * this game's pipelines in **8.1 s** that way.
707
696
  *
708
- * `"object"` walks one representative per pipeline and yields between slices, which is the only
709
- * way to show progress — but it was measured at **more than 2 s per call** on the same device,
710
- * so warming the same scene would take minutes. It is kept, and kept off, because a scene with
711
- * few pipelines can afford it and a progress bar is worth something there; the default may not
712
- * be a mechanism that turns a 8 s launch into a 15 s one.
697
+ * `"object"` walks every renderable candidate and yields between slices, which is the only way
698
+ * to show candidate progress. The renderer owns cache reuse and actual pipeline identity, so the
699
+ * warm-up never drops an object based on a material or geometry guess.
713
700
  */
714
701
  readonly granularity?: "scene" | "object";
702
+ /**
703
+ * Remember a completed warm-up so a later launch can trust the driver's persistent cache.
704
+ * This is opt-in because WebGPU does not expose a portable pipeline-serialization API.
705
+ */
706
+ readonly cache?: IWarmUpCacheOptions;
715
707
  }
716
708
  /** What the warm-up did, so a caller can report it rather than assume it. */
709
+ type WarmUpObservationStatus = "complete" | "incomplete" | "unavailable";
710
+ interface IWarmUpObservation {
711
+ /** Pipeline creations observed between the census snapshots. */
712
+ readonly created: number;
713
+ /** Pipeline creations that failed between the census snapshots. */
714
+ readonly failed: number;
715
+ /** Pipeline creations still pending at the ending census snapshot, delta-adjusted. */
716
+ readonly pending: number;
717
+ /** New shader-program identities observed between the census snapshots. */
718
+ readonly uniquePrograms: number;
719
+ /** New backend pipeline identities observed between the census snapshots. */
720
+ readonly uniquePipelines: number;
721
+ /** Whether the renderer supplied a complete observation for this warm-up. */
722
+ readonly status: WarmUpObservationStatus;
723
+ }
717
724
  interface IWarmUpReport {
718
- /** Distinct pipelines warmed — one representative object each, not one per renderable. */
725
+ /** Compile calls that settled successfully; retained for compatibility with existing callers. */
719
726
  readonly compiled: number;
727
+ /**
728
+ * @deprecated Candidate count retained under the old public name. Use `candidates` for its
729
+ * actual unit and `observed` for backend-created pipelines.
730
+ */
731
+ readonly pipelines: number;
732
+ /** Renderable objects selected for warm-up, before backend cache identity is known. */
733
+ readonly candidates: number;
734
+ /** Number of compileAsync calls attempted, including rejected and timed-out calls. */
735
+ readonly attempted: number;
736
+ /** Backend creation counts observed between the warm-up's start and end snapshots. */
737
+ readonly observed: IWarmUpObservation;
720
738
  /** Slices the work was cut into, and therefore the frames the loop got to present. */
721
739
  readonly slices: number;
722
740
  /** Wall-clock milliseconds the warm-up took, compiling and yielding together. */
@@ -750,11 +768,14 @@ interface IWarmUpReport {
750
768
  readonly computeUnsupported?: boolean;
751
769
  /** True when compute warm-up consumed the startup budget. Present only when computeNodes was set. */
752
770
  readonly computeTimedOut?: boolean;
771
+ /** The optional persistent warm-up hint's outcome. */
772
+ readonly cache?: WarmUpCacheStatus;
753
773
  }
754
774
  /** The narrow slice of the renderer this needs. Structural so a test needs no renderer. */
755
775
  interface IWarmUpRenderer {
756
776
  compileAsync?: (scene: Object3D, camera: Camera, targetScene?: Object3D) => Promise<void>;
757
777
  computeAsync?: (node: unknown) => Promise<void>;
778
+ pipelineCensus?: () => IPipelineCensus;
758
779
  raw?: unknown;
759
780
  }
760
781
  /**
@@ -768,6 +789,165 @@ interface IWarmUpRenderer {
768
789
  */
769
790
  declare function warmUpScene(renderer: IWarmUpRenderer, scene: Object3D, camera: Camera, options?: IWarmUpOptions): Promise<IWarmUpReport>;
770
791
 
792
+ declare abstract class Scene<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> {
793
+ static readonly initialState: Record<string, unknown> | undefined;
794
+ load(_ctx: ICtx<TState, TPhysics>): void | Promise<void>;
795
+ enter(_ctx: ICtx<TState, TPhysics>): SceneEnterResult<TState, TPhysics>;
796
+ exit(_ctx: ICtx<TState, TPhysics>): void;
797
+ update(_ctx: ICtx<TState, TPhysics>, _dt: number): void;
798
+ render(_ctx: ICtx<TState, TPhysics>): void;
799
+ }
800
+ type SceneConstructor<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> = new () => Scene<TState, TPhysics>;
801
+ type SceneFrame<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> = (ctx: ICtx<TState, TPhysics>, dt: number) => void;
802
+ type SceneEnterResult<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> = // biome-ignore lint/suspicious/noConfusingVoidType: void preserves existing Scene.enter overrides.
803
+ void | SceneFrame<TState, TPhysics>;
804
+ /**
805
+ * When the framework's startup milestones happened, in milliseconds on the host's monotonic
806
+ * clock (`performance.now()`: since navigation on the web, since process start on native).
807
+ *
808
+ * Absent members have not happened yet. Published to the playtest bridge so a scenario asserts
809
+ * startup time as an observation rather than reading it off a console log.
810
+ */
811
+ interface IStartupTimeline {
812
+ /** The start scene's `load()` began. */
813
+ readonly loadStartedMs?: number;
814
+ /** The start scene's `enter()` returned: the world is built and the loop can move. */
815
+ readonly enteredMs?: number;
816
+ /** First-use compilation settled or its budget expired. */
817
+ readonly compileSettledMs?: number;
818
+ /**
819
+ * The framework's own launch work finished: compilation settled and the frame window held.
820
+ *
821
+ * Equal to `readyMs` unless the game registered a `startup.hold()`. Kept separate so that making
822
+ * `readyMs` honest about the player's wait does not delete the only measurement of the
823
+ * framework's own cost — a game with a slow asset tier would otherwise hide a framework
824
+ * regression inside its own loading time.
825
+ */
826
+ readonly frameworkReadyMs?: number;
827
+ /**
828
+ * `whenReady()` resolved: the world is safe to show, including anything the game held for.
829
+ *
830
+ * This is the number to compare against what a player experiences. It used to be the
831
+ * framework's own readiness and nothing else, which reported 1.5 s on a valley that took 8.8 s
832
+ * to appear — and `assert.startup`'s `maxReadyMs` passed on it.
833
+ */
834
+ readonly readyMs?: number;
835
+ }
836
+ interface IStartupStatus {
837
+ /** The most recent warm-up accounting, when a warm-up has run. */
838
+ readonly warmup?: {
839
+ readonly attempted?: number;
840
+ readonly candidates?: number;
841
+ readonly observed?: IWarmUpObservation;
842
+ readonly status: WarmUpObservationStatus;
843
+ };
844
+ /**
845
+ * True once first-use compilation has settled — earlier than `phase === "ready"`, which also
846
+ * waits for a sustained in-budget frame window.
847
+ *
848
+ * This is the signal a game wants when something must not run during the launch. `phase` cannot
849
+ * express it: it is binary, and on a software rasteriser the frame window can only ever expire
850
+ * rather than be met, so a game gated on `phase` alone does nothing for tens of seconds there.
851
+ * Measured as a chase route of length `0.000000` against a required `6`, because the scenario
852
+ * ended before the window did.
853
+ */
854
+ readonly compileSettled: boolean;
855
+ /**
856
+ * `collapsing` until first-use work and a sustained in-budget frame window complete, `ready`
857
+ * once the world is safe to show.
858
+ */
859
+ readonly phase: "observing" | "collapsing" | "ready";
860
+ /**
861
+ * 0 to 1, monotonic and honest: the loader's settled/requested ratio carries the first 0.7
862
+ * while the start scene loads, 0.8 once the world is entered, 0.9 once first-use compilation
863
+ * settled, 1 when `whenReady()` resolves.
864
+ */
865
+ readonly progress: number;
866
+ /** When each milestone happened; members appear as they are reached. */
867
+ readonly timeline: IStartupTimeline;
868
+ /** Resolves after first-use work, the sustained frame window, and every game `hold()`. */
869
+ whenReady(): Promise<void>;
870
+ /**
871
+ * Resolves after first-use work and the sustained frame window, and **before** any `hold()`.
872
+ *
873
+ * This is what a game should sequence its own launch work off. `whenReady()` cannot be used for
874
+ * that once the game holds startup: the hold makes readiness wait for the game's work, so work
875
+ * *started* from `whenReady()` waits for a gate that is waiting for it. The cycle is only broken
876
+ * by the hold's budget expiring, which looks exactly like a very slow asset load — measured as a
877
+ * valley revealing its critical tier after a 45 s stall with `trees=1`.
878
+ *
879
+ * Use this when the reason to wait is the framework's launch cost — not competing with first-use
880
+ * compilation, not stealing frames from the stable-frame window — which is the usual reason.
881
+ *
882
+ * @situation start the game's own asset streaming after the framework's launch work, without deadlocking the readiness gate
883
+ */
884
+ whenFrameworkReady(): Promise<void>;
885
+ /**
886
+ * Add the game's own launch work to the readiness gate, so every framework-owned observation of
887
+ * startup describes the moment the player actually reached the world.
888
+ *
889
+ * For a game that streams a second asset tier after the framework is done. Without it the only
890
+ * options are to show a half-built world or to hold a curtain past `whenReady()`, and the second
891
+ * leaves `progress`, `phase`, `timeline.readyMs` and the playtest bridge's `assert.startup` all
892
+ * describing a moment nobody experienced.
893
+ *
894
+ * Fails open twice: a hold that rejects counts as settled, and `budgetMs` bounds how long it may
895
+ * delay the world (45 s by default). A launch slower than it could be is a disappointment; a
896
+ * launch that never finishes because one asset 404'd is a bug.
897
+ *
898
+ * Throws on an empty or duplicate label, and on a hold registered after startup already
899
+ * resolved — each means the caller believes it is gating something it is not.
900
+ *
901
+ * @situation hold the loading screen until the game's own asset tier has landed
902
+ */
903
+ hold(label: string, work: Promise<unknown>, budgetMs?: number): void;
904
+ }
905
+ interface ICtx<TState extends Record<string, unknown> = Record<string, unknown>, TPhysics = undefined> {
906
+ readonly fps: number;
907
+ readonly renderer: IRendererLike;
908
+ readonly viewport: Viewport;
909
+ readonly scene: Scene$1;
910
+ readonly camera: Camera;
911
+ readonly canvasLayer: CanvasLayer;
912
+ readonly entities: Registry;
913
+ /**
914
+ * Adds a node to the scene and hands it straight back, with its own type intact.
915
+ *
916
+ * Generic rather than `Object3D` because a game writes `const sea = ctx.add(new SpectralOcean())`
917
+ * and then calls a method on it. Erasing the type here makes every typed node in every scene need
918
+ * a cast back to what it already was, and that cast is where a game stops noticing it is holding
919
+ * something else.
920
+ */
921
+ readonly add: <T extends Object3D>(object: T) => T;
922
+ readonly input: InputMap;
923
+ readonly pointer: IPointerEvents3D;
924
+ readonly assets: IAssetLoader;
925
+ readonly after: (delay: number, callback: () => void) => ScheduleHandle;
926
+ /** Register a callback for the engine-owned phase after physics writes solved transforms. */
927
+ readonly afterPhysics: (callback: AfterPhysicsCallback) => () => void;
928
+ readonly every: (callback: (dt: number) => void) => ScheduleHandle;
929
+ readonly state: GameStore<TState>;
930
+ readonly tween: <T extends object>(target: T, properties: {
931
+ [K in keyof T]?: number;
932
+ }, duration: number, options?: ITweenOptions) => Promise<void>;
933
+ readonly random: IRandom;
934
+ readonly raycast: (options?: IRaycastOptions) => Intersection | undefined;
935
+ readonly raycastAll: (options?: IRaycastOptions, target?: Intersection[]) => readonly Intersection[];
936
+ /**
937
+ * The framework's own startup work — what a loading screen waits on.
938
+ *
939
+ * A shader may compile the first time something using it is drawn, and the render projection may
940
+ * do its first build in that same frame. Both costs are real and belong before the world is shown.
941
+ *
942
+ * Keeping the world hidden until `whenReady()` resolves does more than hide the mess: the
943
+ * shaders that would have been compiled for geometry the projection then discards are never
944
+ * compiled at all, so waiting is *faster* than not waiting.
945
+ */
946
+ readonly startup: IStartupStatus;
947
+ readonly goto: (name: string) => Promise<void>;
948
+ physics: TPhysics;
949
+ }
950
+
771
951
  type PluginCleanup = () => void;
772
952
  interface IGameObservationSampleRequest {
773
953
  readonly entities?: readonly string[];
@@ -795,14 +975,13 @@ interface IGamePluginRuntime {
795
975
  /** The frame's cost attribution so far, or undefined when the game turned the budget off. */
796
976
  readonly frameBudgetWindow?: () => IFrameBudgetWindow | undefined;
797
977
  /**
798
- * Hold the frame loop until `gate` settles, after the start scene has entered.
978
+ * Hold start-scene entry until `gate` settles.
799
979
  *
800
- * A plugin that blocks its own `setup` blocks it too early: entity-derived capabilities are
801
- * registered by the scene, which runs after plugin setup, so a runner reading `describe()`
802
- * during that hold sees a description missing them. Handing the gate here instead holds the
803
- * loop at the last possible moment — everything is registered, nothing has stepped.
980
+ * The returned promise settles after `Scene.enter()` has run. A runner can therefore release the
981
+ * gate after applying pre-entry setup, then await the returned promise before describing
982
+ * entity-derived capabilities. The frame loop remains held throughout.
804
983
  */
805
- readonly holdStart?: (gate: Promise<void>) => void;
984
+ readonly holdStart?: (gate: Promise<void>) => Promise<void>;
806
985
  readonly observations: IGameRuntimeObservations;
807
986
  readonly tick: () => number;
808
987
  readonly runtimeDiagnosticsSeries?: () => readonly IRenderPerformanceSample[];
@@ -818,6 +997,10 @@ interface IGamePluginRuntime {
818
997
  * must be reported rather than inferred from a phase that cannot distinguish the two.
819
998
  */
820
999
  readonly startupCompileSettled?: () => boolean;
1000
+ /** When the startup milestones happened, for the playtest bridge's startup observation. */
1001
+ readonly startupTimeline?: () => IStartupTimeline;
1002
+ /** The renderer-owned bounded pipeline capture, when the renderer has not been opted out. */
1003
+ readonly pipelineCensus?: () => IPipelineCensus;
821
1004
  readonly step: number;
822
1005
  }
823
1006
  interface IGamePlatformSource {
@@ -860,7 +1043,7 @@ interface IGameConfig<TState extends Record<string, unknown> = Record<string, un
860
1043
  readonly frameBudget?: IFrameBudgetOptions | false;
861
1044
  readonly initialState?: TState;
862
1045
  /**
863
- * Shader warm-up before the first frame. **Off by default, on the evidence below.**
1046
+ * The **pre-start** shader warm-up. Off by default, and that is not the same as no warm-up.
864
1047
  *
865
1048
  * Every distinct pipeline is otherwise built the first time something using it is drawn, inside
866
1049
  * the first rendered frame of a fully built scene. On a Pixel 8 that frame lasted **12.0 s, of
@@ -868,21 +1051,24 @@ interface IGameConfig<TState extends Record<string, unknown> = Record<string, un
868
1051
  * loop presents nothing for the whole span. Warming those pipelines while the loading screen is
869
1052
  * up is the obvious fix, and it is the one this option exists for.
870
1053
  *
871
- * It remains opt-in for callers that want to pay this cost before `start()` releases the held
872
- * loop. On the native host it currently cannot complete: **`renderer.compileAsync()` never
873
- * resolves there**. Measured on the same device, both granularities were abandoned by their own
874
- * budget having compiled nothing —
875
- * `TN_WARMUP:{"compiled":0,"abandoned":1,"timedOut":true,"elapsedMs":15325}` for one
876
- * whole-scene call, and 6 of 490 in 15 s for the per-object walk — while the first frame
877
- * compiled the identical pipelines synchronously in 8.0 s. Turning this on today buys nothing
878
- * and spends the budget waiting, so the default may not be on until the host resolves that
879
- * promise; the seam is the async-pipeline shim in `runtime-native`'s WebGPU bindings.
880
- *
881
- * Set it to `{}` or an options object to enable it — on web, where `compileAsync` does resolve,
882
- * it does what it says. The default loading layer has its own bounded post-enter readiness gate,
883
- * so setting this option is not required for a loading screen to cover first-use work. Either
884
- * way `TN_WARMUP` reports what happened, because turning a convention off must not turn its
885
- * measurement off.
1054
+ * **A game with this unset still warms up.** `startupCompile` runs the same `warmUpScene` from
1055
+ * inside the loading layer's bounded readiness gate, where the opaque layer is on screen and the
1056
+ * loop is turning. This option only moves that work *earlier*, to before `start()` releases the
1057
+ * loop, where nothing is presenting — which is a thing to choose deliberately, not a default.
1058
+ *
1059
+ * **What PRD-327 changed is the mechanism, not this default.** The native host used to answer
1060
+ * `createRenderPipelineAsync` with the synchronous create wrapped in a resolved promise, so
1061
+ * `compileAsync` compiled on the main loop and was abandoned by its own budget having finished
1062
+ * nothing — `TN_WARMUP:{"compiled":0,"abandoned":1,"timedOut":true,"elapsedMs":15325}` — while
1063
+ * the first frame compiled the identical pipelines in 8.0 s anyway. Both entries are native
1064
+ * handlers now, handing the descriptor to a host compile pool and holding the main thread for
1065
+ * 0.27 ms of a 70 ms compile (ratio 0.0038 against a pre-registered bar of 0.25, asserted by
1066
+ * `threenative-async-pipeline-thread-test`). The default path started working without its
1067
+ * default moving.
1068
+ *
1069
+ * Pass `{}` or an options object to opt in, or `false` to opt out of warm-up entirely. Either
1070
+ * way `TN_WARMUP` and `TN_STARTUP_WARMUP` report what happened, because turning a convention off
1071
+ * must not turn its measurement off.
886
1072
  */
887
1073
  readonly warmUp?: IWarmUpOptions | false | true;
888
1074
  readonly inputTarget?: EventTarget;
@@ -977,4 +1163,4 @@ interface IGame<TState extends Record<string, unknown> = Record<string, unknown>
977
1163
  }
978
1164
  declare function defineGame<TState extends Record<string, unknown>, TPhysics = undefined>(config: IGameConfig<TState, TPhysics>): IGame<TState, TPhysics>;
979
1165
 
980
- export { type ITweenOptions as A, type IWarmUpOptions as B, type ContextMenuPolicy as C, type IWarmUpProgress as D, type IWarmUpRenderer as E, type IWarmUpReport as F, type InputBindings as G, type InputPlatformSource as H, type IGame as I, type PointerEvent3DType as J, PointerEvents3D as K, type SceneFrame as L, ScenePicker as M, type ScheduleHandle as N, Scheduler as O, type PointerEvent3DListener as P, type ThreeNativeOrientation as Q, type ThreeNativeUiRenderer as R, Scene as S, type ThreeNativeBackgroundMode as T, createAssetLoader as U, createRandom as V, defineGame as W, warmUpScene as X, type IGamePluginRuntime as a, type IGamePluginHooks as b, type IAssetLoader as c, type IAssetLoaderOptions as d, type ICtx as e, type IGameObservationContribution as f, type IGameObservationSampleRequest as g, type IGamePlatformSource as h, type IInputAction as i, type IInputGamepad as j, type IPointerDragHandle as k, type IPointerEvent3D as l, type IPointerEvents3D as m, type IPointerEvents3DOptions as n, type IPointerEvents3DPicker as o, type IPointerState as p, type IRandom as q, type IRawInputPointer as r, type IRawInputPointerEdge as s, type IRawInputState as t, type IRaycastOptions as u, type IScenePickerOptions as v, type IThreeNativeBootSplash as w, type IThreeNativeConfig as x, type IThreeNativeIconVariants as y, type IThreeNativeTexturesConfig as z };
1166
+ export { afterPhysics as $, type AfterPhysicsCallback as A, type IThreeNativeIconVariants as B, type ContextMenuPolicy as C, type IThreeNativeTexturesConfig as D, type ITweenOptions as E, type IWarmUpCacheOptions as F, type IWarmUpObservation as G, type IWarmUpOptions as H, type IGame as I, type IWarmUpProgress as J, type IWarmUpRenderer as K, type IWarmUpReport as L, type InputBindings as M, type InputPlatformSource as N, type PointerEvent3DType as O, type PointerEvent3DListener as P, PointerEvents3D as Q, type SceneFrame as R, Scene as S, ScenePicker as T, type ScheduleHandle as U, Scheduler as V, type ThreeNativeBackgroundMode as W, type ThreeNativeOrientation as X, type ThreeNativeUiRenderer as Y, type WarmUpCacheStatus as Z, type WarmUpObservationStatus as _, type IGamePluginRuntime as a, createRandom as a0, defineGame as a1, warmUpScene as a2, type IGamePluginHooks as b, type ICtx as c, type IGameObservationContribution as d, type IGameObservationSampleRequest as e, type IGamePlatformSource as f, type IInputAction as g, type IInputGamepad as h, type IPointerDragHandle as i, type IPointerEvent3D as j, type IPointerEvents3D as k, type IPointerEvents3DOptions as l, type IPointerEvents3DPicker as m, type IPointerState as n, type IRandom as o, type IRawInputPointer as p, type IRawInputPointerEdge as q, type IRawInputState as r, type IRaycastOptions as s, type IScenePickerOptions as t, type IThreeNativeAudioConfig as u, type IThreeNativeAudioLoop as v, type IThreeNativeAudioOverride as w, type IThreeNativeAudioSpectrum as x, type IThreeNativeBootSplash as y, type IThreeNativeConfig as z };