diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index ec77cdd52..59258c3d5 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -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 — 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 — 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 @@ -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 @@ -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. @@ -2326,7 +2332,7 @@ - `AudioSettingsBridge` (function): function AudioSettingsBridge({ store, engine, buses, }: { store: SettingsStore; engine: AudioEngine; buses: Record | 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>>): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile } — ⚠ undocumented - `useSettingsRevision` (function): function useSettingsRevision(store: SettingsStore): number — ⚠ undocumented ## @jgengine/shell/settings/settingsController @@ -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 — 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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index dcfa4d74e..81d69d341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/src/game/playableGame.ts b/packages/core/src/game/playableGame.ts index 80af231f8..b88110f1c 100644 --- a/packages/core/src/game/playableGame.ts +++ b/packages/core/src/game/playableGame.ts @@ -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"; @@ -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>>; /** 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. */ diff --git a/packages/core/src/settings/graphicsProfile.test.ts b/packages/core/src/settings/graphicsProfile.test.ts new file mode 100644 index 000000000..76be6e242 --- /dev/null +++ b/packages/core/src/settings/graphicsProfile.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/settings/graphicsProfile.ts b/packages/core/src/settings/graphicsProfile.ts new file mode 100644 index 000000000..85a0103bc --- /dev/null +++ b/packages/core/src/settings/graphicsProfile.ts @@ -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 = { + 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 { + const base = DEFAULT_GRAPHICS_PROFILES[quality]; + return { + ...base, + ...overrides, + postStages: { ...base.postStages, ...overrides?.postStages }, + }; +} diff --git a/packages/shell/src/GamePlayerShell.tsx b/packages/shell/src/GamePlayerShell.tsx index cfad32507..63e3b5608 100644 --- a/packages/shell/src/GamePlayerShell.tsx +++ b/packages/shell/src/GamePlayerShell.tsx @@ -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; diff --git a/packages/shell/src/Shell3dPresentation.tsx b/packages/shell/src/Shell3dPresentation.tsx index 476398790..3d7a810fb 100644 --- a/packages/shell/src/Shell3dPresentation.tsx +++ b/packages/shell/src/Shell3dPresentation.tsx @@ -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, @@ -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; @@ -505,7 +506,7 @@ export function Shell3dPresentation({ /> ) : null} {lighting !== undefined ? ( - + ) : effectiveSky === undefined ? ( <> @@ -530,7 +531,7 @@ export function Shell3dPresentation({ ) : null} - + ) : null} - + {devtoolsEnabled ? : null} @@ -614,7 +615,7 @@ export function Shell3dPresentation({ /> {resolvedLook.postProcessing !== undefined && resolvedLook.postProcessing.enabled !== false ? ( - + ) : null} {!poster && diff --git a/packages/shell/src/defineGame.tsx b/packages/shell/src/defineGame.tsx index fc7ece462..84d5a7184 100644 --- a/packages/shell/src/defineGame.tsx +++ b/packages/shell/src/defineGame.tsx @@ -131,6 +131,7 @@ export function defineGame( look, postProcessing, shadows, + graphics, presentation, objectStyles, devtools, @@ -218,6 +219,7 @@ export function defineGame( backdrop: resolvedLook.backdrop, postProcessing: resolvedLook.postProcessing, shadows, + graphics, presentation, objectStyles, devtools, diff --git a/packages/shell/src/postfx/PostProcessing.tsx b/packages/shell/src/postfx/PostProcessing.tsx index fdb7e4e1b..eeb83d9a3 100644 --- a/packages/shell/src/postfx/PostProcessing.tsx +++ b/packages/shell/src/postfx/PostProcessing.tsx @@ -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); @@ -118,8 +115,8 @@ 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({ @@ -127,12 +124,12 @@ export function PostProcessing({ config, quality = "high" }: { config: PostProce 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( @@ -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()); } @@ -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; diff --git a/packages/shell/src/render/SceneLighting.tsx b/packages/shell/src/render/SceneLighting.tsx index 921cc2eb1..6b4aa401c 100644 --- a/packages/shell/src/render/SceneLighting.tsx +++ b/packages/shell/src/render/SceneLighting.tsx @@ -8,6 +8,7 @@ import type { PointLightingConfig, SpotLightingConfig, } from "@jgengine/core/game/playableGame"; +import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile"; import { CascadedShadows } from "./CascadedShadows"; @@ -37,11 +38,12 @@ function usesCascades(entry: DirectionalLightingConfig): boolean { return (entry.castShadow ?? false) && (entry.cascades ?? 1) > 1; } -function DirectionalShadowLight({ entry }: { entry: DirectionalLightingConfig }) { - if (usesCascades(entry)) { - return ; +function DirectionalShadowLight({ entry, profile }: { entry: DirectionalLightingConfig; profile?: GraphicsProfile }) { + const resolved = profile === undefined ? entry : { ...entry, shadowMapSize: entry.shadowMapSize ?? profile.shadowMapSize, cascades: entry.cascades ?? profile.cascades }; + if (usesCascades(resolved)) { + return ; } - return ; + return ; } /** @@ -89,7 +91,7 @@ function SingleShadowLight({ entry }: { entry: DirectionalLightingConfig }) { ); } -export function ConfiguredLighting({ lighting }: { lighting: LightingConfig }) { +export function ConfiguredLighting({ lighting, profile }: { lighting: LightingConfig; profile?: GraphicsProfile }) { const dynamic = resolveDynamicLights(lighting); return ( <> @@ -106,7 +108,7 @@ export function ConfiguredLighting({ lighting }: { lighting: LightingConfig }) { /> ) : null} {(lighting.directional ?? []).map((entry, index) => ( - + ))} {dynamic.point.map((entry, index) => ( >>, +): { shadows: boolean; dpr: number; uiScale: number; quality: GraphicsQuality; profile: GraphicsProfile } { useSettingsRevision(store); const rawQuality = store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY) as GraphicsQuality; const quality: GraphicsQuality = GRAPHICS_QUALITY_DPR[rawQuality] !== undefined ? rawQuality : "high"; + const profile = resolveGraphicsProfile(quality, overrides?.[quality]); const rawUiScale = store.get(SETTING_IDS.graphicsUiScale, DEFAULT_UI_SCALE); return { shadows: store.get(SETTING_IDS.graphicsShadows, shadowsDefault), - dpr: GRAPHICS_QUALITY_DPR[quality], + dpr: profile.renderScale, uiScale: Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, rawUiScale)), quality, + profile, }; } diff --git a/packages/shell/src/visibility/CullingProvider.tsx b/packages/shell/src/visibility/CullingProvider.tsx index c01d7d5b7..42c61f7f0 100644 --- a/packages/shell/src/visibility/CullingProvider.tsx +++ b/packages/shell/src/visibility/CullingProvider.tsx @@ -144,12 +144,16 @@ function viewFromCamera(camera: THREE.Camera): CameraView | null { * 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. */ -export function CullingProvider({ config, children }: { config: VisibilityConfig | undefined; children: ReactNode }): ReactNode { +export function CullingProvider({ config, drawDistance, children }: { config: VisibilityConfig | undefined; drawDistance?: number; children: ReactNode }): ReactNode { const ctx = useGameContext(); const enabled = config?.enabled !== false; const predicateRef = useRef(ALWAYS_VISIBLE); const camera = useThree((state) => state.camera); - const driver = useMemo(() => (enabled ? buildSystem(ctx, config) : null), [ctx, config, enabled]); + const resolvedConfig = useMemo(() => { + if (drawDistance === undefined) return config; + return { ...config, culling: { ...config?.culling, defaultMaxRenderDistance: config?.culling?.defaultMaxRenderDistance ?? drawDistance } }; + }, [config, drawDistance]); + const driver = useMemo(() => (enabled ? buildSystem(ctx, resolvedConfig) : null), [ctx, resolvedConfig, enabled]); useFrame(() => { if (driver === null) { diff --git a/packages/shell/src/world/WorldParticles.tsx b/packages/shell/src/world/WorldParticles.tsx index 28dd30d5b..56f3db923 100644 --- a/packages/shell/src/world/WorldParticles.tsx +++ b/packages/shell/src/world/WorldParticles.tsx @@ -15,8 +15,8 @@ const QUALITY_POOL_CAP: Record = { low: 128, medium: 25 * clamped to the tier cap, so lower tiers degrade density instead of dropping * effects entirely. */ -export function resolveParticleBudget(quality: GraphicsQuality, requested: number | undefined): number { - const cap = QUALITY_POOL_CAP[quality]; +export function resolveParticleBudget(quality: GraphicsQuality, requested: number | undefined, particleCap?: number): number { + const cap = Math.max(1, Math.floor(particleCap ?? QUALITY_POOL_CAP[quality])); return Math.max(1, Math.min(requested ?? cap, cap)); } @@ -60,7 +60,7 @@ export function specNeedsRender(prev: ParticleEmitterSpec, next: ParticleEmitter * * @internal */ -export function WorldParticles({ quality }: { quality: GraphicsQuality }): ReactElement { +export function WorldParticles({ quality, particleCap }: { quality: GraphicsQuality; particleCap?: number }): ReactElement { const ctx = useGameContext(); const emittersRef = useRef(new Map()); const burstsRef = useRef([]); @@ -74,7 +74,7 @@ export function WorldParticles({ quality }: { quality: GraphicsQuality }): React const liveIds = new Set(); for (const spec of specs) { liveIds.add(spec.id); - const poolMax = resolveParticleBudget(quality, spec.config.max); + const poolMax = resolveParticleBudget(quality, spec.config.max, particleCap); const current = emitters.get(spec.id); if (current === undefined || current.poolMax !== poolMax) { emitters.set(spec.id, { @@ -95,7 +95,7 @@ export function WorldParticles({ quality }: { quality: GraphicsQuality }): React changed = true; } for (const burst of ctx.particles.drainBursts()) { - const poolMax = resolveParticleBudget(quality, Math.min(burst.count, burst.config.max ?? burst.count)); + const poolMax = resolveParticleBudget(quality, Math.min(burst.count, burst.config.max ?? burst.count), particleCap); const system = createParticleSystem({ ...burst.config, max: poolMax, seed: burst.seq }); system.emit(burst.count); burstsRef.current.push({ seq: burst.seq, system, blending: burst.blending ?? "additive" }); @@ -111,7 +111,7 @@ export function WorldParticles({ quality }: { quality: GraphicsQuality }): React emitters.clear(); burstsRef.current = []; }; - }, [ctx, quality]); + }, [ctx, quality, particleCap]); useFrame(() => { for (const instance of emittersRef.current.values()) { diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 0a3c494cb..7ef1dd1bd 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -391,6 +391,7 @@ "./session/ring", "./session/roles", "./session/roundState", + "./settings/graphicsProfile", "./settings/settingsModel", "./stats/accumulatorMeter", "./stats/eventMeter",