From 5155831eed6eeaa5d0722bf8e214c8d7fa778005 Mon Sep 17 00:00:00 2001 From: Nitin Khanna Date: Mon, 22 Jun 2026 14:31:35 -0700 Subject: [PATCH] Fix play-start jank and import hangs for material-heavy scenes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine-side fixes surfaced by the TinySkies playground port — a material-heavy procedural globe game that exposed startup stalls, in-flight jank, and an import-time hang. None are game-specific; they benefit any large OSS scene. - featureFlags: enable shared `@import` scripts in OSS unconditionally. REACT_APP_SCRIPTS_ENABLED is unset everywhere, so every behavior that `@import`s a helper failed to import. Scripts are a first-class OSS authoring feature; integrated keeps the env opt-in. - Editor / RenderEvent / RenderingQualityModule: make the scene's serialized `useShadows` the source of truth and seed the renderer's shadow tracking from it. A no-shadow scene was paying shadow-variant shader compiles + a full-scene shadow pass every frame, and the first post-(re)create frame spuriously marked every material needsUpdate. Adaptive quality may still downgrade shadows but never force them on. - EngineRuntime: precompile material pipelines with compileAsync before the loop starts, and hold the loading overlay until the first frame renders — folding one-time shader compiles into play-start load instead of streaming jank in mid-play. Both best-effort/bounded. - GameManager: init behaviors in small batches that yield to paint, so the loading overlay advances instead of the UI freezing during a large world build. - BehaviorManager: drop the per-behavior fixedUpdate "not implemented" warning — dozens of warn-with-stacktrace calls stalled play start for no benefit; not implementing an optional hook is normal. - Editor.runScriptImport: pause the editor render loop during a bulk script import so per-command cost stays flat (was ~O(n^2), a multi-minute hang that could OOM the renderer). - remote-go thumbnail: guard the `/api/Scene/Edit` POST behind !IS_OSS; OSS has no such endpoint, so it 404-spammed on every import. Adds the port planning doc under docs/planning/. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../packages/editor-oss/src/EngineRuntime.ts | 47 ++++++++++ .../src/behaviors/BehaviorManager.ts | 20 ++--- .../src/behaviors/game/GameManager.ts | 89 +++++++++++-------- .../quality/modules/RenderingQualityModule.ts | 14 ++- .../packages/editor-oss/src/editor/Editor.ts | 38 ++++++++ .../editor-oss/src/event/RenderEvent.js | 18 +++- .../editor-oss/src/utils/featureFlags.ts | 8 +- .../src/adapters/remote-go/scene/thumbnail.ts | 8 ++ .../2026-06-19-tinyskies-playground-port.md | 76 ++++++++++++++++ 9 files changed, 266 insertions(+), 52 deletions(-) create mode 100644 docs/planning/2026-06-19-tinyskies-playground-port.md diff --git a/client/packages/editor-oss/src/EngineRuntime.ts b/client/packages/editor-oss/src/EngineRuntime.ts index 8705fc2f..d6f56055 100644 --- a/client/packages/editor-oss/src/EngineRuntime.ts +++ b/client/packages/editor-oss/src/EngineRuntime.ts @@ -1785,6 +1785,31 @@ export class EngineRuntime extends AppRuntime implements RuntimeContext { ); SceneLoadProfiler.end("gameCreate"); + // Pre-compile all material render pipelines now that the scene is + // fully built (behaviors have created their geometry/materials in + // init), but before the animation loop starts. Without this, each + // unique material compiles its WGSL/GLSL pipeline lazily the first + // frame it is rendered — so a game with many distinct materials + // (e.g. tinyskies' globe villages / landmarks / monuments) streams + // shader compiles in as the camera reveals new objects mid-play, + // producing periodic main-thread stalls ("jank every few frames"). + // compileAsync folds that one-time cost into the play-start load + // (covered by the loading overlay) instead. Best-effort; a compile + // failure must never block entering play. + try { + const r = this.renderer as unknown as { + hasInitialized?: () => boolean; + compileAsync?: (scene: unknown, camera: unknown) => Promise; + }; + if (this.scene && this.camera && r?.compileAsync && (r.hasInitialized?.() ?? true)) { + SceneLoadProfiler.begin("precompileShaders"); + await r.compileAsync(this.scene, this.camera); + SceneLoadProfiler.end("precompileShaders"); + } + } catch (e) { + console.warn("[Application] shader pre-compile failed (non-fatal):", e); + } + this.playerEvent?.init(); this.clock.start(); this.playerEvent?.start(); @@ -1812,6 +1837,28 @@ export class EngineRuntime extends AppRuntime implements RuntimeContext { this.playerMask.hide(); } + // Hold the loading overlay until the first frame has actually + // rendered. That first render is where any not-yet-warmed material + // pipelines compile (a multi-second block on material-heavy scenes). + // Completing loading before it dismisses the overlay onto a frozen + // game canvas; waiting two animation frames keeps the loading screen + // up across the compile so the hand-off reads as "loading" rather + // than a hang. Bounded so a stalled rAF can never wedge startup. + await new Promise(resolve => { + if (typeof requestAnimationFrame !== "function") { + resolve(); + return; + } + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + requestAnimationFrame(() => requestAnimationFrame(finish)); + setTimeout(finish, 8000); + }); + this.loadingManager.completeLoading(); console.debug("🎮 [Application] ✅ startPlayer completed successfully"); } catch (err: any) { diff --git a/client/packages/editor-oss/src/behaviors/BehaviorManager.ts b/client/packages/editor-oss/src/behaviors/BehaviorManager.ts index 8c15a2e7..ca941990 100644 --- a/client/packages/editor-oss/src/behaviors/BehaviorManager.ts +++ b/client/packages/editor-oss/src/behaviors/BehaviorManager.ts @@ -98,7 +98,6 @@ class BehaviorManager { game: GameManager; // Track which behaviors have already shown a given warning (to avoid spamming console) - private static _fixedUpdateWarnings = new Set(); private static _deprecationWarnings = new Set(); // Dependency injection instead of singleton - industry standard approach @@ -571,17 +570,18 @@ class BehaviorManager { const behavior = behaviors[i]!; if (behavior.isPaused) continue; + // Fast-path: most behaviors only implement update() — not the + // optional fixedUpdate() hook. Skip them silently. (Previously + // this warned once per behavior, but with the fixed-rate + // scheduler on a behavior-heavy scene that is dozens of + // console.warn-with-stack-trace calls at play start, which — + // especially with DevTools open — measurably stalls startup for + // no benefit. Not implementing an optional hook is normal.) + if (typeof behavior.fixedUpdate !== "function") continue; + try { behaviorProfiler.beginMeasure(behavior.uuid); - if (typeof behavior.fixedUpdate === "function") { - behavior.fixedUpdate(fixedDeltaTime); - } else if (!BehaviorManager._fixedUpdateWarnings.has(behavior.id)) { - console.warn( - `[Behavior] ${this.formatBehaviorId(behavior.id)} does not implement fixedUpdate(). ` + - `Skipping in FIXED_UPDATE stage. For fixed-rate logic, implement fixedUpdate().`, - ); - BehaviorManager._fixedUpdateWarnings.add(behavior.id); - } + behavior.fixedUpdate(fixedDeltaTime); behaviorProfiler.endMeasure(behavior.uuid, behavior.id); } catch (error) { console.error( diff --git a/client/packages/editor-oss/src/behaviors/game/GameManager.ts b/client/packages/editor-oss/src/behaviors/game/GameManager.ts index 45745a32..c80dc0ad 100644 --- a/client/packages/editor-oss/src/behaviors/game/GameManager.ts +++ b/client/packages/editor-oss/src/behaviors/game/GameManager.ts @@ -856,48 +856,67 @@ class GameManager { //sort priorities (low to high - lower values execute first) const sortedPriorities = Array.from(behaviorsByPriority.keys()).sort((a, b) => a - b); + + // Progressive init: a behavior's init()/onAdded builds its geometry + // synchronously. Running every behavior in a priority group back-to-back + // (a single `Promise.all` over the whole group) blocks the main thread + // for the entire world build — the loading overlay can't paint and the + // UI appears frozen for seconds before play starts. Instead we process + // behaviors in small batches and yield to the event loop between them, + // so the browser paints the loading overlay and progress advances + // smoothly. Priority ordering (low→high) and intra-group ordering are + // preserved; same-priority behaviors are order-independent by design. + const INIT_BATCH = 2; + const totalBehaviors = allBehaviors.length || 1; + let processedBehaviors = 0; + const yieldToPaint = () => + new Promise(resolve => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => resolve()); + } else { + setTimeout(() => resolve(), 0); + } + }); + //TODO: in onAdded behaviors may add other behaviors, so we need to set BM.isProcessing = true here for (let i = 0; i < sortedPriorities.length; i++) { const priority = sortedPriorities[i]; const behaviors = behaviorsByPriority.get(priority!)!; console.log(`[GameManager] Processing ${behaviors.length} behaviors with priority ${priority}`); - const promises: Promise[] = []; - for (const behavior of behaviors) { - const target = behaviorToTargetMap.get(behavior.uuid)!; - console.debug( - `[GameManager] About to add behavior "${behavior.id}" (uuid: ${behavior.uuid}) to object "${target.name}" (uuid: ${target.uuid})`, - ); - - const options = { - uuid: behavior.uuid, - attributes: behavior.attributesData, - throttleConfig: behavior.throttleConfig, - }; - const promise = this.addBehaviorToObject(target, behavior.id, options) - .then(() => { - console.debug( - `[GameManager] ✓ Successfully added behavior "${behavior.id}" to object "${target.name}"`, - ); - }) - .catch(error => { - console.error( - `[GameManager] ✗ Failed to add behavior ${behavior.id} to object ${target.name}:`, - error, - ); - }); - promises.push(promise); - } - try { - await Promise.all(promises); - console.log(`[GameManager] All behaviors with priority ${priority} initialized successfully`); - } catch (error) { - console.error( - `[GameManager] Failed to initialize some behaviors with priority ${priority} (ignoring):`, - error, - ); + for (let b = 0; b < behaviors.length; b += INIT_BATCH) { + const batch = behaviors.slice(b, b + INIT_BATCH); + const promises = batch.map(behavior => { + const target = behaviorToTargetMap.get(behavior.uuid)!; + const options = { + uuid: behavior.uuid, + attributes: behavior.attributesData, + throttleConfig: behavior.throttleConfig, + }; + return this.addBehaviorToObject(target, behavior.id, options) + .then(() => { + console.debug( + `[GameManager] ✓ Successfully added behavior "${behavior.id}" to object "${target.name}"`, + ); + }) + .catch(error => { + console.error( + `[GameManager] ✗ Failed to add behavior ${behavior.id} to object ${target.name}:`, + error, + ); + }); + }); + try { + await Promise.all(promises); + } catch (error) { + console.error(`[GameManager] Failed to initialize a behavior batch (ignoring):`, error); + } + processedBehaviors += batch.length; + this.engine.loadingManager?.updateStageProgress(Math.min(1, processedBehaviors / totalBehaviors)); + // Let the browser paint the loading overlay between batches. + await yieldToPaint(); } - this.engine.loadingManager?.updateStageProgress((i + 1) / sortedPriorities.length); + console.log(`[GameManager] All behaviors with priority ${priority} initialized`); } console.log("[GameManager] Finished createBehaviorsFromScene"); diff --git a/client/packages/editor-oss/src/core/quality/modules/RenderingQualityModule.ts b/client/packages/editor-oss/src/core/quality/modules/RenderingQualityModule.ts index 407dcb98..765193a3 100644 --- a/client/packages/editor-oss/src/core/quality/modules/RenderingQualityModule.ts +++ b/client/packages/editor-oss/src/core/quality/modules/RenderingQualityModule.ts @@ -1,5 +1,6 @@ import * as THREE from 'three'; +import global from '../../../global'; // EffectRenderer already lives in editor-oss after the render/ migration. import EffectRenderer from '../../../render/EffectRenderer'; import type { IQualityModule, IQualitySettings, IPerformanceMetrics } from '../interfaces/IQualityManager'; @@ -198,10 +199,17 @@ export class RenderingQualityModule implements IQualityModule { private applyShadowSettings(settings: IQualitySettings['rendering']): void { if (!this.runtimeRenderer?.shadowMap) return; + // Respect the scene's explicit shadow choice. Adaptive quality may + // DOWNGRADE (turn shadows off on weak devices) but must never ENABLE + // shadows on a scene that disabled them — otherwise a no-shadow game + // (e.g. tinyskies, useShadows=false) pays shadow-variant shader + // compiles + a full shadow pass over the whole scene every frame. + const sceneAllowsShadows = global.app?.editor?.useShadows !== false; + // Enable/disable shadows - this.runtimeRenderer.shadowMap.enabled = settings.shadowQuality !== 'none'; - - if (settings.shadowQuality === 'none') return; + this.runtimeRenderer.shadowMap.enabled = sceneAllowsShadows && settings.shadowQuality !== 'none'; + + if (!this.runtimeRenderer.shadowMap.enabled) return; // Set shadow map type based on quality switch (settings.shadowQuality) { diff --git a/client/packages/editor-oss/src/editor/Editor.ts b/client/packages/editor-oss/src/editor/Editor.ts index fc5cc5be..9e2139a3 100644 --- a/client/packages/editor-oss/src/editor/Editor.ts +++ b/client/packages/editor-oss/src/editor/Editor.ts @@ -380,10 +380,24 @@ class Editor { this.sceneConfig.useInstancing = v; } get useShadows() { + // Authoritative source is the scene's serialized game settings — that's + // what `render settings useShadows=…` writes (SettingsHandlers) and what + // persists across save/reload. `sceneConfig.useShadows` is only an + // in-memory mirror that defaults to true and was never synced from the + // scene, so reading it directly made the renderer ignore a scene that + // explicitly disabled shadows (forcing shadow-variant shader compiles + + // a shadow pass over the whole scene). Prefer the scene value; fall back + // to the mirror/default when a scene never set it. + const game = this.scene?.userData?.game; + if (game && typeof game.useShadows === "boolean") return game.useShadows; return this.sceneConfig.useShadows; } set useShadows(v) { this.sceneConfig.useShadows = v; + if (this.scene) { + if (!this.scene.userData.game) this.scene.userData.game = {}; + this.scene.userData.game.useShadows = v; + } } get rendering() { return this.sceneConfig.rendering; @@ -2088,11 +2102,35 @@ class Editor { * import dispatches another scripted import inside its own callback. */ async runInScriptImportContext(fn: () => Promise): Promise { + // Suspend the editor render loop for the duration of a bulk script + // import. Otherwise the loop re-renders the whole scene after every + // command; as a heavy game adds hundreds of meshes + unique TSL + // materials, each interleaved frame gets slower (and TSL reshades + // churn), degrading the import to ~O(n^2) — a multi-minute hang that + // can OOM the renderer. The loop keeps ticking but `animate()` + // early-returns while paused, so per-command cost stays flat. We + // resume (one render of the final scene) on the outermost exit. + // Uses the existing `pauseRender`/`resumeRender` events (RenderEvent). + const outermost = this._scriptImportDepth === 0; this._scriptImportDepth += 1; + if (outermost) { + try { + global.app?.call("pauseRender"); + } catch { + /* render pause is best-effort */ + } + } try { return await fn(); } finally { this._scriptImportDepth -= 1; + if (this._scriptImportDepth === 0) { + try { + global.app?.call("resumeRender"); + } catch { + /* render resume is best-effort */ + } + } } } diff --git a/client/packages/editor-oss/src/event/RenderEvent.js b/client/packages/editor-oss/src/event/RenderEvent.js index c003f3f0..9133d1cf 100644 --- a/client/packages/editor-oss/src/event/RenderEvent.js +++ b/client/packages/editor-oss/src/event/RenderEvent.js @@ -203,9 +203,21 @@ class RenderEvent extends BaseEvent { this.renderer.dispose(); } - // Force shadow settings to be re-applied on the new renderer instance. - this.prevUseShadows = null; - this.prevShadowMapType = -1; + // Initialise shadow tracking from the CURRENT scene/editor state and + // configure the renderer's shadow map up-front. Previously these were + // reset to sentinels (null / -1), so the first animate() frame after + // every (re)create saw a spurious "shadow changed" and marked EVERY + // material needsUpdate — a full pipeline recompile on the first play + // frame for no reason. Seeding the real values lets materials compile + // once (already shadow-correct); only a genuine later shadow toggle + // takes the recompile path. + this.prevUseShadows = this.app.editor?.useShadows ?? false; + this.prevShadowMapType = this.app.editor?.rendering?.shadowMapType ?? -1; + if (renderer?.shadowMap) { + renderer.shadowMap.enabled = this.prevUseShadows; + if (this.prevShadowMapType >= 0) renderer.shadowMap.type = this.prevShadowMapType; + renderer.shadowMap.needsUpdate = true; + } this.renderer = new EffectRenderer(); this.app.effectRenderer = this.renderer; diff --git a/client/packages/editor-oss/src/utils/featureFlags.ts b/client/packages/editor-oss/src/utils/featureFlags.ts index 73b00132..64fcb930 100644 --- a/client/packages/editor-oss/src/utils/featureFlags.ts +++ b/client/packages/editor-oss/src/utils/featureFlags.ts @@ -8,5 +8,11 @@ import {IS_OSS} from "../mode/buildMode"; export const isStripeCreditsPurchasingEnabled = (): boolean => !IS_OSS && import.meta.env.REACT_APP_STRIPE_CREDITS_ENABLED === "true"; +// Shared `@import` scripts (reusable JS/YAML helpers consumed by behaviors and +// lambdas) are a first-class OSS authoring feature — the stemscript-folder +// import pipeline, docs/import-packs.md, and several shipped example games +// (2048, drop7, island-defense, machine-arena, sky-bomber, tinyskies) all rely +// on `import script` + `@import "name" as X;`. They are always on in OSS; the +// integrated build keeps the env-flag opt-in. export const isScriptsEnabled = (): boolean => - import.meta.env.REACT_APP_SCRIPTS_ENABLED === "true"; + IS_OSS || import.meta.env.REACT_APP_SCRIPTS_ENABLED === "true"; diff --git a/client/packages/network/src/adapters/remote-go/scene/thumbnail.ts b/client/packages/network/src/adapters/remote-go/scene/thumbnail.ts index 6f4b64c9..e220c45d 100644 --- a/client/packages/network/src/adapters/remote-go/scene/thumbnail.ts +++ b/client/packages/network/src/adapters/remote-go/scene/thumbnail.ts @@ -65,6 +65,14 @@ export async function migrateSceneThumbnailIfNeeded( * @param thumbnailUrl */ export async function updateSceneThumbnail(sceneId: string, sceneName: string, thumbnailUrl: string): Promise { + // OSS has no `/api/Scene/Edit` endpoint (scene metadata lives in the local + // ProjectStore, not a hosted Mongo). Posting here 404s and spams the + // console on every `scene thumbnail` command during a stemscript import. + // Callers reflect the thumbnail into `editor.sceneConfig.sceneThumbnail` + // locally, which persists through the OSS save path. Mirrors the + // `setSceneAiPromptMode` OSS guard below. + if (IS_OSS) return; + const editResponse = await Ajax.post({ url: backendUrlFromPath(`/api/Scene/Edit`), data: { diff --git a/docs/planning/2026-06-19-tinyskies-playground-port.md b/docs/planning/2026-06-19-tinyskies-playground-port.md new file mode 100644 index 00000000..09343aba --- /dev/null +++ b/docs/planning/2026-06-19-tinyskies-playground-port.md @@ -0,0 +1,76 @@ +# TinySkies → StemStudio playground port (run + extend fidelity) + +Goal: make the existing tinyskies port (`/Users/n/erth/Games-StemScript/tinyskies`) +import and run in playground mode (editor **Play** + standalone **/play/**), +then extend the deliberately-capped systems toward fuller source fidelity. +Source game: "GlobeFly" — fly a biplane around a procedural globe + paintball. + +Scope decision (user): **Run first, then extend fidelity. Both play targets.** + +## Diagnosis (done) + +- The port is mature: 41 behaviors, 7 GLB models, 5 shared `@import` scripts, + 6 FDRs. Its own FDR/source-map docs are **stale** (they describe caps that + have since been un-capped; the requirements doc's "all Implemented" is closer). +- **Root cause of "doesn't work in playground":** the shared-scripts feature + (`import script` + `@import "name" as X;`) is gated behind + `isScriptsEnabled()` = `REACT_APP_SCRIPTS_ENABLED === "true"`, which is **unset + everywhere** in the repo (dev + build). With scripts off, every behavior that + `@import`s a helper (`terrain`, `spherical-math`, `biplane-mesh`, + `vehicle-meshes`, `uikit-dual-mode`) fails to import → + `Unable to resolve import "terrain" as T`. All 41 behaviors collapse; the + imported scene is just the 7 hidden models. This affects **6 shipped games** + (2048, drop7, island-defense, machine-arena, sky-bomber, tinyskies). +- Proven via instrumented import probe: with scripts off, `behavior {n:10, fail:10}`, + no `script` imports run. With scripts on, `script {fail:0}`, `behavior {fail:0}`. + +## Fix 1 — enable shared scripts in OSS (DONE, verify) + +- `client/packages/editor-oss/src/utils/featureFlags.ts`: `isScriptsEnabled()` + now returns `IS_OSS || env`. Scripts are a first-class OSS authoring feature + (docs/import-packs.md, the stemscript-folder import pipeline); only Stripe is + hard-gated off in OSS. Integrated keeps the env opt-in. +- [x] Behaviors import (failCount 11 → 1; the 1 is the cosmetic `scene thumbnail`). +- [x] Core subset (globe/biplane/flight/camera/paintball/day-night/controls-hud) + imports and **plays with zero console errors** — biplane flies over the + terrain globe, chase cam tracks. (screenshot: /tmp/tinyskies-diag-core-play.png) +- [ ] Full 41-behavior import completes and plays (verifying). +- [ ] `/play/` of the saved project runs. + +## Fix 2 — import performance (one-time cost, but blocks the smoke + UX) + +Even with scripts on, import is slow: model ~12s each (7≈90s), behavior ~7s each +(41≈300s), script ~4s each. Full import ≈ 8–12 min. It's a one-time cost (the +saved scene reloads fast), but the playground smoke's import-wait window is +~126s, so it would save a partial scene. Targets: +- [ ] Model load: each 130 KB GLB takes ~12s via `[AssetLoader] No suitable + derivative … fetching revision` + a 6-strategy `[TextureMapping] findTexture + ALL STRATEGIES FAILED` storm. Investigate; likely a quick win. +- [ ] Per-behavior import ~7s: profile `createBehavior` / + `updateSceneBehaviorRevision` path. +- [ ] If residual slowness remains, bump the heavy-game import-wait in + `scripts/playwright/oss-all-games-playground.mjs`. + +## Fix 3 — cosmetic: `scene thumbnail` → `POST /api/Scene/Edit` 404 in OSS + +`thumbnail.ts` hits the integrated server endpoint; should be a no-op / local in OSS. + +## Phase 2 — extend fidelity (after it runs) + +Re-derive the real gap vs source `/Users/n/erth/tinyskies/client/src` (docs are +stale). Known un-ported source systems: HUD (1890 LOC), AudioManager, FlagSystem +(MP hot-potato), MoonThreat (fail state), OceanFish, Braziers, Volcano, +SkyJellyfish, BirdFlock, RainOverlay (weather), UpgradeManager/LevelUpCards, +PilotAvatar, Campsite, multiplayer Lobby + RemotePlayerNameLabels + paintball relay. +Break into per-system subtasks once the gap map is complete. + +## Validation + +- [ ] `bun run typecheck`, `bun run lint` (NOT eslint --fix) +- [ ] `bun run test` (Vitest) — featureFlags / scriptImports tests still green +- [ ] Playwright: `oss-all-games-playground.mjs` GAMES=tinyskies → PASS, Play renders globe+plane +- [ ] `/play/` loads the saved project and runs +- [ ] Re-run the other 5 script-using games' smoke (the flag change affects them) +- [ ] **Manual code review** +- [ ] Remove temp diagnostics: `__stemImportTimings` (useTerminal.ts), + `scripts/playwright/_tinyskies-diag.mjs`