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
16 changes: 11 additions & 5 deletions .claude/skills/jgengine-ui/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@
- `createSpriteClipPlayer` (function): function createSpriteClipPlayer(atlas: SpriteAtlas): { play(name: string): void; advance(dt: number): void; frame: () => { x: number; y: number; w: number; h: number; pivot?: [number, number] | undefined; } | undefined; snapshot: () => { animation: string; frameIndex: number; elapsed: number; done: … — Create a renderer-independent sprite animation player.
- `sortingOrder` (function): function sortingOrder(layers: readonly string[], layer: string, offset = 0): number — Resolve a layer name and local offset into a stable render order.

## @jgengine/core/settings/graphicsProfile

- `DEFAULT_GRAPHICS_PROFILES` (const): const DEFAULT_GRAPHICS_PROFILES: Record<GraphicsQuality, GraphicsProfile> — Conservative defaults that preserve the existing high-quality shell behavior.
- `GraphicsProfile` (interface): interface GraphicsProfile — Serializable renderer budget selected by the player's graphics tier.
- `resolveGraphicsProfile` (function): function resolveGraphicsProfile(quality: GraphicsQuality, overrides?: Partial<GraphicsProfile>): GraphicsProfile — Resolve one tier and apply game-authored field and post-stage overrides.

## @jgengine/core/settings/settingsModel

- `BUILT_IN_SETTING_CATEGORIES` (const): const BUILT_IN_SETTING_CATEGORIES: readonly BuiltInSettingCategory[] — ⚠ undocumented
Expand Down Expand Up @@ -2138,7 +2144,7 @@

## @jgengine/shell/postfx/PostProcessing

- `PostProcessing` (function): function PostProcessing({ config, quality = "high" }: { config: PostProcessingConfig; quality?: GraphicsQuality }): null — Mounts an `EffectComposer` inside the shell Canvas and takes over rendering (priority-1 `useFrame`, which disables R3F auto-render) to run the configured post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
- `PostProcessing` (function): function PostProcessing({ config, quality = "high", stages }: { config: PostProcessingConfig; quality?: GraphicsQuality; stages?: { ao: boolean; bloom: boolean; dof: boolean; smaa: boolean } }): null — Mounts an `EffectComposer` inside the shell Canvas and takes over rendering (priority-1 `useFrame`, which disables R3F auto-render) to run the configured post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only when `PlayableGame.postProcessing` is set, so games without it draw unchanged.

## @jgengine/shell/postfx/ScreenEffectsOverlay

Expand Down Expand Up @@ -2181,7 +2187,7 @@
## @jgengine/shell/render/SceneLighting

- `BackdropFog` (function): function BackdropFog({ fog }: { fog: BackdropConfig["fog"] }): React.JSX.Element | null — ⚠ undocumented
- `ConfiguredLighting` (function): function ConfiguredLighting({ lighting }: { lighting: LightingConfig }): React.JSX.Element — ⚠ undocumented
- `ConfiguredLighting` (function): function ConfiguredLighting({ lighting, profile }: { lighting: LightingConfig; profile?: GraphicsProfile }): React.JSX.Element — ⚠ undocumented
- `POINT_LIGHT_BUDGET` (const): const POINT_LIGHT_BUDGET: 8 — Default cap for combined dynamic point and spot lights.
- `syncShadowProjection` (function): function syncShadowProjection(light: DirectionalLight): void — Refresh a directional light's shadow projection after its frustum bounds change. R3F pierced `shadow-camera-*` props write the bounds but never call `updateProjectionMatrix()`, so depth renders with the stale default ~10×10 box and `shadowCameraSize` is silently inert.

Expand Down Expand Up @@ -2326,7 +2332,7 @@

- `AudioSettingsBridge` (function): function AudioSettingsBridge({ store, engine, buses, }: { store: SettingsStore; engine: AudioEngine; buses: Record<string, AudioBusDef> | undefined; }): null — ⚠ undocumented
- `TOUCH_STYLE_AUTO` (const): const TOUCH_STYLE_AUTO: "auto" — Sentinel Controls value meaning "defer to the game's suggested touch skin".
- `useGraphicsSettings` (function): function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality } — ⚠ undocumented
- `useGraphicsSettings` (function): function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean, overrides?: Partial<Record<GraphicsQuality, Partial<GraphicsProfile>>>): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile } — ⚠ undocumented
- `useSettingsRevision` (function): function useSettingsRevision(store: SettingsStore): number — ⚠ undocumented

## @jgengine/shell/settings/settingsController
Expand Down Expand Up @@ -2609,7 +2615,7 @@

## @jgengine/shell/visibility/CullingProvider

- `CullingProvider` (function): function CullingProvider({ config, children }: { config: VisibilityConfig | undefined; children: ReactNode }): ReactNode — Drives automatic frustum + distance culling for every entity and placed object. It reads the live render camera each frame, updates the engine VisibilitySystem, and exposes a predicate the entity/object markers consult to toggle `group.visible` — objects fully outside the view (plus a conservative preload margin) are never submitted to the renderer, without unmounting them or touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected.
- `CullingProvider` (function): function CullingProvider({ config, drawDistance, children }: { config: VisibilityConfig | undefined; drawDistance?: number; children: ReactNode }): ReactNode — Drives automatic frustum + distance culling for every entity and placed object. It reads the live render camera each frame, updates the engine VisibilitySystem, and exposes a predicate the entity/object markers consult to toggle `group.visible` — objects fully outside the view (plus a conservative preload margin) are never submitted to the renderer, without unmounting them or touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected.
- `useRenderVisibility` (function): function useRenderVisibility(): MutableRefObject<VisiblePredicate> — Read the current render-visibility predicate. Backward compatible: with no CullingProvider mounted (or culling disabled) it returns an always-visible ref, so a marker that consults it behaves exactly as before this feature existed.

## @jgengine/shell/vision/FrustumSensorHud
Expand Down Expand Up @@ -2841,7 +2847,7 @@

## @jgengine/shell/world/WorldParticles

- `resolveParticleBudget` (function): function resolveParticleBudget(quality: GraphicsQuality, requested: number | undefined): number — The per-effect particle pool a graphics tier allows: the requested `max` clamped to the tier cap, so lower tiers degrade density instead of dropping effects entirely.
- `resolveParticleBudget` (function): function resolveParticleBudget(quality: GraphicsQuality, requested: number | undefined, particleCap?: number): number — The per-effect particle pool a graphics tier allows: the requested `max` clamped to the tier cap, so lower tiers degrade density instead of dropping effects entirely.

## @jgengine/shell/world/WorldPings

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ between (`--json` for structured output).
- Material map overrides now support metalness, emissive, and height maps on standard and physical materials.
- Presentation and editor documents now accept gradient, HDRI/EXR, and cube-map environment sources.
- Authored `light` markers now feed bounded point/spot punctual lights with an eight-light default budget and editor warning.
- Graphics profiles now resolve per-tier render scale, shadow budgets, culling distance, particles, cascades, and post stages; games can override them with `defineGame({ graphics })`.
- `defineGame({ physics: { backend } })` now adopts a capsule character controller for shell/headless movement and registers the backend simulation after movement, including floor collision and step-up behavior.

### Migrate
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/game/playableGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { SpriteAtlas } from "../assets/spriteAtlas";
import type { LookPreset } from "../render/lookPreset";
import type { TouchControlsConfig } from "../input/touchScheme";
import type { GameSettingsConfig } from "../settings/settingsModel";
import type { GraphicsProfile } from "../settings/graphicsProfile";
import type { GameOrientation } from "../ui/orientation";
import type { HudPlatform, HudViewportConfig } from "../ui/hudScale";
import type { PositionedPrompt } from "../interaction/proximityPrompt";
Expand Down Expand Up @@ -562,6 +563,8 @@ export interface PlayableGame<
capture?: GameCaptureConfig;
/** Cast/receive shadows across the scene (R3F Canvas shadow pass). Default true. */
shadows?: boolean;
/** Per-quality renderer budgets; omitted tiers use {@link DEFAULT_GRAPHICS_PROFILES}. */
graphics?: Partial<Record<"low" | "medium" | "high", Partial<GraphicsProfile>>>;
/** Pointer-driven input: click-to-move, box-select, right-click verbs, cursor aim (#22/#30/#31). */
pointer?: PointerConfig;
/** Touch controls on coarse-pointer devices. Unset derives a scheme from `input` (virtual joystick for movement actions, on-screen buttons for the rest); a config refines it with gestures and curated buttons; `false` opts out. */
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/settings/graphicsProfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "bun:test";

import { DEFAULT_GRAPHICS_PROFILES, resolveGraphicsProfile } from "./graphicsProfile";

describe("resolveGraphicsProfile", () => {
it("returns the tier defaults", () => {
expect(resolveGraphicsProfile("low")).toEqual(DEFAULT_GRAPHICS_PROFILES.low);
expect(resolveGraphicsProfile("high")).toEqual(DEFAULT_GRAPHICS_PROFILES.high);
});

it("merges partial fields and nested post stages without mutating defaults", () => {
const profile = resolveGraphicsProfile("medium", {
renderScale: 1.25,
postStages: { ao: false },
});
expect(profile.renderScale).toBe(1.25);
expect(profile.shadowMapSize).toBe(1024);
expect(profile.postStages).toEqual({ ao: false, bloom: true, dof: true, smaa: true });
expect(DEFAULT_GRAPHICS_PROFILES.medium.postStages.ao).toBe(true);
});
});
52 changes: 52 additions & 0 deletions packages/core/src/settings/graphicsProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { GraphicsQuality } from "./settingsModel";

/** Serializable renderer budget selected by the player's graphics tier. */
export interface GraphicsProfile {
renderScale: number;
shadowMapSize: 512 | 1024 | 2048 | 4096;
cascades: 1 | 2 | 3 | 4;
drawDistance: number;
particleCap: number;
postStages: { ao: boolean; bloom: boolean; dof: boolean; smaa: boolean };
}

/** Conservative defaults that preserve the existing high-quality shell behavior. */
export const DEFAULT_GRAPHICS_PROFILES: Record<GraphicsQuality, GraphicsProfile> = {
low: {
renderScale: 1,
shadowMapSize: 512,
cascades: 1,
drawDistance: 120,
particleCap: 128,
postStages: { ao: false, bloom: true, dof: false, smaa: true },
},
medium: {
renderScale: 1.5,
shadowMapSize: 1024,
cascades: 2,
drawDistance: 240,
particleCap: 256,
postStages: { ao: true, bloom: true, dof: true, smaa: true },
},
high: {
renderScale: 2,
shadowMapSize: 2048,
cascades: 4,
drawDistance: 400,
particleCap: 512,
postStages: { ao: true, bloom: true, dof: true, smaa: true },
},
};

/** Resolve one tier and apply game-authored field and post-stage overrides. */
export function resolveGraphicsProfile(
quality: GraphicsQuality,
overrides?: Partial<GraphicsProfile>,
): GraphicsProfile {
const base = DEFAULT_GRAPHICS_PROFILES[quality];
return {
...base,
...overrides,
postStages: { ...base.postStages, ...overrides?.postStages },
};
}
2 changes: 1 addition & 1 deletion packages/shell/src/GamePlayerShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export function GamePlayerShell({
() => createActionStateTracker(toActionStateBindingMap(withTouchCodes(effectiveInput))),
[effectiveInput],
);
const graphics = useGraphicsSettings(settingsStore, playable.shadows ?? true);
const graphics = useGraphicsSettings(settingsStore, playable.shadows ?? true, playable.graphics);
const trackPointerAxis = (event: { clientX: number; clientY: number }) => {
const rect = wrapperRef.current?.getBoundingClientRect();
if (rect === undefined) return;
Expand Down
11 changes: 6 additions & 5 deletions packages/shell/src/Shell3dPresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { DEFAULT_PICKUP_RADIUS } from "@jgengine/core/game/worldItem";
import type { PointerConfig } from "@jgengine/core/game/playableGame";
import { CAMERA_FRUSTUM_DEFAULTS } from "@jgengine/core/game/playableGame";
import type { GameSettingsConfig } from "@jgengine/core/settings/settingsModel";
import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
import {
BUILT_IN_SETTING_CATEGORIES,
type GraphicsQuality,
Expand Down Expand Up @@ -164,7 +165,7 @@ export function Shell3dPresentation({
orientationGateEl: React.ReactNode;
coarsePointer: boolean;
compact: boolean;
graphics: { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality };
graphics: { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile };
settingsStore: SettingsStore;
bindingOverrides: BindingOverrides;
rebindAction: (action: string, code: string) => void;
Expand Down Expand Up @@ -505,7 +506,7 @@ export function Shell3dPresentation({
/>
) : null}
{lighting !== undefined ? (
<ConfiguredLighting lighting={lighting} />
<ConfiguredLighting lighting={lighting} profile={graphics.profile} />
) : effectiveSky === undefined ? (
<>
<ambientLight intensity={0.55} />
Expand All @@ -530,7 +531,7 @@ export function Shell3dPresentation({
) : null}
<BackdropFog fog={backdrop?.fog} />
<GameProvider context={ctx}>
<CullingProvider config={playable.visibility}>
<CullingProvider config={playable.visibility} drawDistance={graphics.profile.drawDistance}>
<WorldView
entitySprites={playable.entitySprites}
entityModels={playable.entityModels}
Expand Down Expand Up @@ -571,7 +572,7 @@ export function Shell3dPresentation({
/>
) : null}
<WorldItems config={playable.worldItem} />
<WorldParticles quality={graphics.quality} />
<WorldParticles quality={graphics.quality} particleCap={graphics.profile.particleCap} />
<CombatPresentation effects={effects} />
{devtoolsEnabled ? <CollisionDebugWorld /> : null}
<AudioListener engine={audioEngine} />
Expand Down Expand Up @@ -614,7 +615,7 @@ export function Shell3dPresentation({
/>
<DevtoolsRendererProbe />
{resolvedLook.postProcessing !== undefined && resolvedLook.postProcessing.enabled !== false ? (
<PostProcessing config={resolvedLook.postProcessing} quality={graphics.quality} />
<PostProcessing config={resolvedLook.postProcessing} quality={graphics.quality} stages={graphics.profile.postStages} />
) : null}
</Canvas>
{!poster &&
Expand Down
2 changes: 2 additions & 0 deletions packages/shell/src/defineGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function defineGame<TAssetRef extends ModelAssetRef = ModelAssetRef>(
look,
postProcessing,
shadows,
graphics,
presentation,
objectStyles,
devtools,
Expand Down Expand Up @@ -218,6 +219,7 @@ export function defineGame<TAssetRef extends ModelAssetRef = ModelAssetRef>(
backdrop: resolvedLook.backdrop,
postProcessing: resolvedLook.postProcessing,
shadows,
graphics,
presentation,
objectStyles,
devtools,
Expand Down
27 changes: 12 additions & 15 deletions packages/shell/src/postfx/PostProcessing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,11 @@ function syncSize(built: BuiltGraph, width: number, height: number, pixelRatio:
* post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only
* when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
*
* `quality` (the player's graphics-quality setting) gates the passes whose cost
* scales with scene geometry or resolution beyond the dpr cap: GTAO re-renders
* the whole scene for depth/normals and runs a multi-sample full-screen pass,
* and Bokeh DOF renders scene depth again — both run on "high" only. Bloom,
* SMAA, tone mapping, and grade stay on every tier (SMAA is the cheap edge fix
* for alpha-tested foliage that MSAA samples alone leave crawling).
* `stages` (resolved from the player's graphics profile) controls expensive passes
* independently. Medium keeps AO and DOF with reduced AO sampling; low can disable
* them while retaining cheaper bloom, SMAA, tone mapping, and grade stages.
*/
export function PostProcessing({ config, quality = "high" }: { config: PostProcessingConfig; quality?: GraphicsQuality }) {
export function PostProcessing({ config, quality = "high", stages }: { config: PostProcessingConfig; quality?: GraphicsQuality; stages?: { ao: boolean; bloom: boolean; dof: boolean; smaa: boolean } }) {
const gl = useThree((s) => s.gl);
const scene = useThree((s) => s.scene);
const camera = useThree((s) => s.camera);
Expand All @@ -118,21 +115,21 @@ export function PostProcessing({ config, quality = "high" }: { config: PostProce
const composer = new EffectComposer(gl, target);
composer.addPass(new RenderPass(scene, camera));

const heavyPasses = quality === "high";
if (heavyPasses && config.ao !== undefined && config.ao !== false) {
const resolvedStages = stages ?? { ao: quality === "high", bloom: true, dof: quality === "high", smaa: true };
if (resolvedStages.ao && config.ao !== undefined && config.ao !== false) {
const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
ao.blendIntensity = config.ao.blend ?? 1;
ao.updateGtaoMaterial({
radius: config.ao.radius ?? 1.8,
distanceExponent: 1,
thickness: config.ao.distanceFalloff ?? 3.6,
scale: config.ao.intensity ?? 2.4,
samples: 16,
samples: quality === "high" ? 16 : 8,
});
composer.addPass(ao);
}

if (config.bloom !== false) {
if (resolvedStages.bloom && config.bloom !== false) {
const b = config.bloom ?? {};
composer.addPass(
new UnrealBloomPass(
Expand All @@ -145,18 +142,18 @@ export function PostProcessing({ config, quality = "high" }: { config: PostProce
}

let dof: BokehPass | null = null;
if (heavyPasses && config.dof !== undefined && config.dof !== false) {
if (resolvedStages.dof && config.dof !== undefined && config.dof !== false) {
const d = config.dof;
dof = new OverlayAwareBokehPass(scene, camera, {
focus: d.focus ?? 18,
aperture: d.aperture ?? 0.00025,
aperture: d.aperture ?? (quality === "high" ? 0.00025 : 0.00012),
maxblur: d.maxBlur ?? 0.01,
});
composer.addPass(dof);
}

// SMAA must run before OutputPass (linear-sRGB). Default on for post chains.
if (aa === "smaa") {
if (resolvedStages.smaa && aa === "smaa") {
composer.addPass(new SMAAPass());
}

Expand All @@ -175,7 +172,7 @@ export function PostProcessing({ config, quality = "high" }: { config: PostProce
disposeGraph(graph);
builtRef.current = null;
};
}, [gl, scene, camera, config, quality]);
}, [gl, scene, camera, config, quality, stages]);

useEffect(() => {
const lut = config.grade !== undefined && config.grade !== false ? config.grade.lut : undefined;
Expand Down
Loading
Loading