Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions client/packages/editor-oss/src/EngineRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
};
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();
Expand Down Expand Up @@ -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<void>(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) {
Expand Down
20 changes: 10 additions & 10 deletions client/packages/editor-oss/src/behaviors/BehaviorManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
private static _deprecationWarnings = new Set<string>();

// Dependency injection instead of singleton - industry standard approach
Expand Down Expand Up @@ -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(
Expand Down
89 changes: 54 additions & 35 deletions client/packages/editor-oss/src/behaviors/game/GameManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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<void>[] = [];
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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions client/packages/editor-oss/src/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2088,11 +2102,35 @@ class Editor {
* import dispatches another scripted import inside its own callback.
*/
async runInScriptImportContext<T>(fn: () => Promise<T>): Promise<T> {
// 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 */
}
}
}
}

Expand Down
18 changes: 15 additions & 3 deletions client/packages/editor-oss/src/event/RenderEvent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion client/packages/editor-oss/src/utils/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ export async function migrateSceneThumbnailIfNeeded(
* @param thumbnailUrl
*/
export async function updateSceneThumbnail(sceneId: string, sceneName: string, thumbnailUrl: string): Promise<void> {
// 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: {
Expand Down
Loading
Loading