@jgengine/shell 0.32.0 → 0.34.0
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.
- package/CHANGELOG.md +58 -0
- package/dist/GamePlayerShell.js +7 -0
- package/dist/Shell3dPresentation.js +5 -3
- package/dist/audio/AudioComponents.js +61 -51
- package/dist/audio/FeedbackHost.js +8 -4
- package/dist/audio/audioEngine.js +101 -24
- package/dist/audio/emitterTracking.d.ts +40 -0
- package/dist/audio/emitterTracking.js +153 -0
- package/dist/audio/eventFeedback.d.ts +15 -5
- package/dist/audio/eventFeedback.js +12 -7
- package/dist/audio/hapticsRoute.d.ts +28 -0
- package/dist/audio/hapticsRoute.js +59 -0
- package/dist/audio/loopParams.js +20 -10
- package/dist/devtools/FrameTelemetry.d.ts +1 -1
- package/dist/devtools/FrameTelemetry.js +4 -2
- package/dist/devtools/agentBridge.js +13 -0
- package/dist/devtools/frameSampler.js +4 -0
- package/dist/drivers/CanvasCoverFrameloop.d.ts +13 -0
- package/dist/drivers/CanvasCoverFrameloop.js +22 -0
- package/dist/drivers/FrameRateLimiter.d.ts +2 -1
- package/dist/drivers/FrameRateLimiter.js +6 -3
- package/dist/postfx/PostProcessing.d.ts +78 -2
- package/dist/postfx/PostProcessing.js +149 -76
- package/dist/postfx/postfxOverlay.d.ts +14 -4
- package/dist/postfx/postfxOverlay.js +90 -10
- package/dist/postfx/shadowPrepass.d.ts +6 -2
- package/dist/postfx/shadowPrepass.js +7 -3
- package/dist/render/characterLod.js +6 -0
- package/dist/render/useFootIk.js +54 -15
- package/dist/render/useModelAnimation.js +22 -5
- package/dist/settings/settingsController.d.ts +10 -1
- package/dist/settings/settingsController.js +81 -69
- package/dist/vision/RevealVision.d.ts +3 -1
- package/dist/vision/RevealVision.js +34 -15
- package/dist/world/WorldItems.js +11 -3
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/api.md +45 -24
- package/skills/jgengine-ui/capabilities.md +8 -0
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-world/api.md +7 -5
- package/skills/jgengine-world/reference.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,64 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
26
26
|
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
27
|
-->
|
|
28
28
|
|
|
29
|
+
## 0.34.0
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.34.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.31.0`; `@jgengine/github` is `0.21.0`.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `createRunLoop` (`@jgengine/core/run/runLoop`) holds a run's at-risk haul, banks it scaled by the line or loses it on a failure, and draws one authored change for each next run from a seeded table, so the same seed and run history replay the same sequence. State round-trips through `snapshot`/`restore`; the money stays in `progression/careerBank`.
|
|
38
|
+
|
|
39
|
+
## 0.33.0
|
|
40
|
+
|
|
41
|
+
### Migrate
|
|
42
|
+
|
|
43
|
+
- **Bump lockstep SDK packages to `^0.33.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.30.0`; `@jgengine/github` is `0.20.0`.
|
|
44
|
+
- `useSceneObjects` (`@jgengine/react/hooks`) keeps one list identity across `move` and `rotate`, so a record's `position`/`rotationY` is what it was at the last render. Read the live placement with `ctx.scene.object.get(instanceId)` (a `useFrame` loop or a rAF-ticked HUD) when a mesh or readout must follow a moving object.
|
|
45
|
+
- A read of a mismatched stored value now converts losslessly where it can (a number as its digits, a boolean as `"true"`/`"false"`, those strings back) and throws where it cannot. No in-repo caller relied on the old fallback; `useSetting` (`@jgengine/react/settings`) is the generic path that could, so a game that stored a number under a key it reads with a string fallback now gets the digits.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- `releasePostfxOverlayTracking(scene)` (`@jgengine/shell/postfx/postfxOverlay`) drops the cached prepass overlay set and its listeners for a scene that is being torn down.
|
|
50
|
+
- `canvasCover` (`@jgengine/react/canvasCover`): one page-wide hold a covering surface takes, plus `useCanvasCover(declared)` for a React overlay. While it is held the shell stops drawing the world, its shadow passes and postfx; input, audio and the DOM HUD keep running, and the first frame after release draws the untouched scene. A scrim or side panel must not take it.
|
|
51
|
+
- `MenuScreen` and `TitleMenu` take `coversCanvas` (default false) to declare that the screen paints over the whole canvas. A menu with only a translucent scrim leaves the live scene drawing.
|
|
52
|
+
- The dev runner pins the cover with `?cover=1`, and the devtools agent bridge answers `canvas_cover`, so a perf run or `bun run drive` can measure a covered frame on any game.
|
|
53
|
+
- `combat.damage` carries an optional `position`: the effect path fills it from the target's own spot, and `createDamageRuntime` takes one per hit (`hit({ position })`) or from a `positionOf` reader, so a status tick is placed too. A cue with a position pans and attenuates; one without stays centred.
|
|
54
|
+
- `createHapticsRoute` (`@jgengine/shell/audio/hapticsRoute`) sends a feedback pulse to a connected gamepad, and to `navigator.vibrate` on a phone with no pad. `FeedbackHost` wires it; reduced motion still drops the camera shake only.
|
|
55
|
+
- `PerfReport.render.drawsFrame` is every draw a frame issues — shadow, main, postfx prepasses, AO and DOF together — as the 95th percentile of the measured window, with `drawsFrames` naming how many frames it covers. `bun run perf:gate` compares it against each game's re-based `drawsFrame` budget; the renderer's own single-sample `draws` is printed beside it and is no longer gated.
|
|
56
|
+
- `ObjectStore.version()` / `ObjectStore.markSpatialChange()` (`ctx.scene.object`) — a counter that moves whenever membership, a position, a rotation or a consumer-owned collider set changes, so derived spells spatial data can share one cache key.
|
|
57
|
+
- `SolidObstacleSource.version?()` (`@jgengine/core/movement/solidObstacles`) — a source that reports one gets its blocking obstacles resolved once per version into a coarse XZ index; a source without it keeps the per-query `inBox` gather.
|
|
58
|
+
- `SettingsTypeError` and `asSettingType` (`@jgengine/core/settings/settingsModel`) report and resolve a stored setting read as another type.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- `bun run land` queues landings on one lock per repo instead of running them beside each other, so two lanes no longer stack two pull requests' sixteen jobs against the account's 20-job cap. A waiting landing names the branch and pid it waits on; a lock whose process died, or that is older than 30 minutes, is broken with a warning.
|
|
63
|
+
- The scene io browser test skips Playwright's two-frame click stability check and lets three rendered frames pass instead of ten: on a runner its second test went from 53s to 11-24s and the file from 76s to 50s, of which 16-36s is the fixture's own start.
|
|
64
|
+
- `check-game-front-end` recognizes the shipped `CreditsPanel`, so a game that renders credits through the engine's panel is no longer flagged as having no credits screen.
|
|
65
|
+
- The shell's frame loop switches to `frameloop="never"` under a cover instead of drawing a world nobody can see (measured on Brightway Park's pause menu: 1,095 whole-frame draws and 5.2 ms/frame before, 0 draws and 0.11 ms/frame covered). A frame-rate cap no longer advances a covered canvas.
|
|
66
|
+
- `FeedbackDeps.play` takes an optional `position`, which `FeedbackHost` forwards to `ctx.game.audio.play`, so a positioned damage or death cue reaches the spatial engine.
|
|
67
|
+
- Each game's `perf-budget.json` entry now budgets `drawsFrame` and records the budget it replaced as `previousDraws`; an entry that still carries only `draws` fails by name instead of gating a count from a different measurement.
|
|
68
|
+
- The in-page report reads the devtools render probe's frame sample instead of the live renderer counter, so `window.__JG.report()` and the devtools panel no longer report a mid-frame partial count.
|
|
69
|
+
- Movement gathers obstacles from that index instead of re-resolving every nearby object every frame. On Harbor Heat the query fell from 0.62-0.65 ms to 0.10 ms a frame (2.0-2.1 ms to 1.4 ms of simulation a frame).
|
|
70
|
+
- `objectStore.inBox` walks a coarse grid for big boxes instead of scanning every placed object; a 1 m cell lookup costs a string hash, so the switch happens past 32 cells rather than 4096.
|
|
71
|
+
- Ambient occlusion reads the beauty pass's depth buffer instead of rendering the scene a second time for its normal/depth GBuffer. That prepass cost 537 extra draws and 2.6-2.9 ms/frame in Harbor Heat and 2,105 extra draws in Drift Foundry at 1600x900; AO now draws nothing of its own. A post chain whose antialiasing is `"smaa"` (the default) keeps SMAA and drops the beauty target's extra MSAA resolve, because WebGL2 cannot sample a multisampled depth texture; `"msaa"` chains keep the old prepass. Fixed-viewpoint captures differ only on geometry edges (#2173).
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
|
|
75
|
+
- Postfx prepasses no longer walk the whole scene twice a frame to hide overlays, points and lines: the set is cached per scene and rescanned when the scene graph changes. Drift Foundry 2.11 ms/frame and Harbor Heat 0.66 ms/frame of `traverse` removed.
|
|
76
|
+
- A rig posed from an animation graph no longer allocates a motion sample, a position triple and a fresh pose record every frame, and foot IK no longer allocates a probe input, filter, closure and per-leg records every frame.
|
|
77
|
+
- The Graphics tab lists a post-processing stage toggle only when the game's resolved chain can build that stage. Depth of field, which no reference game authors, hid behind a row that switched a pass the composer never created, so the toggle changed nothing. The rule covers every stage: a chain with `ao: false`, `bloom: false` or `aa: "msaa"`/`false` no longer offers those rows, and a game with no post chain gets none of them. Every other settings row is unchanged (`@jgengine/shell/settings/settingsController`, `availablePostfxStages` in `@jgengine/shell/postfx/PostProcessing`).
|
|
78
|
+
- Positional loops declared through `entitySounds`/`objectSounds` are tracked instead of rebuilt: spawn, despawn and a moved emitter update one retained loop each, and a frame where nothing moved reads only the emitters that already sound. On scrap-signal's 560 placed props (69 sounding), the tracking path drops from 16.7 µs to 1.2 µs per frame and a steady frame allocates no Set, array or record.
|
|
79
|
+
- `useAccessibilitySettings`, `useHoldVsToggle` and `useAccessibilityRows` show a setting written before the component subscribed — a saved profile or the demo path seeding values before first paint — instead of waiting for the player's next change (#2180).
|
|
80
|
+
- `useFeed`, `useQuestJournal` and `useFriends` (`@jgengine/react/hooks`) keep one list identity while membership holds instead of allocating a new array on every store write and comparing it with `Object.is`, so a HUD reading them no longer re-renders on every entity pose write. A pushed feed entry, an accepted, progressed or abandoned quest, and an accepted or removed friend each re-render once. Measured on the three read sites (`ToastStack`, `FriendsList`, `JournalQuestTracker`) with 15 quests, 20 friends and 20 feed rows: 900 renders → 0 across 300 pose writes, 0.14 ms/frame → 0.04 ms/frame.
|
|
81
|
+
- `useWorldItems` (`@jgengine/react/hooks`) keeps one list identity until an item spawns, is picked up or is removed. It read `worldItem.list()`, which allocates a new array per call, against a store signal that also fires on every entity pose write, so a HUD re-rendered every dropped-item marker each frame (0.97 ms/frame of React work in Harbor Heat).
|
|
82
|
+
- `WorldItems` (`@jgengine/shell`) reads each marker's live pose in `useFrame` instead of taking it as a prop, so a moving item still follows without re-rendering its marker.
|
|
83
|
+
- `useSceneEntities` and `useSceneObjects` (`@jgengine/react/hooks`) keep one list identity while membership holds, instead of allocating a new array on every store write and comparing it with `Object.is`. A pose write no longer re-renders every HUD that reads them (30-entity roster: 0.066 ms/frame → 0; the whole loop 0.24 → 0.05 ms/frame), while a spawn, despawn, replace, place or remove still re-renders. Non-pose object writes (state, visual, animation, slots, catalog id) still re-render.
|
|
84
|
+
- `RevealHighlights` (`@jgengine/shell/vision`) drives its own per-frame refresh only while Dark Sight is enabled, so a disabled reveal overlay no longer re-renders (0.019 ms/frame → 0) and an enabled one still follows the origin and its targets.
|
|
85
|
+
- `createSettingsStore().get` returns a stored setting of another type instead of quietly returning the fallback, so a numeric setting read with a string fallback applies instead of vanishing (#2189). Root cause: `coerce` treated the fallback's type as the stored type and replaced every other type with the fallback. A stored value that cannot be read as the asked-for type now throws `SettingsTypeError` naming the key, the stored type and the fallback type.
|
|
86
|
+
|
|
29
87
|
## 0.32.0
|
|
30
88
|
|
|
31
89
|
### Migrate
|
package/dist/GamePlayerShell.js
CHANGED
|
@@ -10,6 +10,7 @@ import { localPlayers } from "@jgengine/core/runtime/localPlayers";
|
|
|
10
10
|
import { actionContextStack, activeActionCodes } from "@jgengine/core/game/controlGate";
|
|
11
11
|
import { useDisplayProfile } from "@jgengine/react/display";
|
|
12
12
|
import { RotateDeviceScreen } from "@jgengine/react/rotateDevice";
|
|
13
|
+
import { canvasCover } from "@jgengine/react/canvasCover";
|
|
13
14
|
import { createSettingsStore } from "@jgengine/core/settings/settingsModel";
|
|
14
15
|
import { parseGraphicsQuality, withGraphicsQualityOverride } from "@jgengine/core/settings/graphicsSettings";
|
|
15
16
|
import { applyBindingOverrides, clearBindingOverride, loadBindingOverrides, saveBindingOverride, } from "@jgengine/core/input/bindingOverrides";
|
|
@@ -45,6 +46,8 @@ import { installPlayFocusReclaim } from "./playSurfaceFocus.js";
|
|
|
45
46
|
const DEV_USER_ID = "dev-player";
|
|
46
47
|
/** The query param that mirrors the devtools overlay into the URL — present = open. */
|
|
47
48
|
const DEBUG_PARAM = "debug";
|
|
49
|
+
/** The query param that pins the canvas cover, so a perf run can measure a covered frame. */
|
|
50
|
+
const COVER_PARAM = "cover";
|
|
48
51
|
const NO_SUBSCRIBE = () => () => undefined;
|
|
49
52
|
const ZERO_VERSION = () => 0;
|
|
50
53
|
function shellRootSnapshot(ctx, userId) {
|
|
@@ -88,6 +91,10 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
|
|
|
88
91
|
return;
|
|
89
92
|
return subscribeUrlChange(() => setDevtoolsOpen(readUrlFlag(DEBUG_PARAM)));
|
|
90
93
|
}, [devtoolsEnabled]);
|
|
94
|
+
// `?cover=1` holds the canvas cover for the page, so a perf run can measure a covered frame on
|
|
95
|
+
// any game without a menu. Games declare the same hold from their own covering surface.
|
|
96
|
+
const coverPinned = readUrlFlag(COVER_PARAM);
|
|
97
|
+
useEffect(() => (coverPinned ? canvasCover.hold() : undefined), [coverPinned]);
|
|
91
98
|
useEffect(() => installAgentBridge({
|
|
92
99
|
playable,
|
|
93
100
|
devtoolsEnabled,
|
|
@@ -48,6 +48,7 @@ import { resolvePlayableAudio } from "./audio/playableAudio.js";
|
|
|
48
48
|
import { AudioSettingsBridge } from "./settings/appliedSettings.js";
|
|
49
49
|
import { TextureFiltering } from "./render/TextureFiltering.js";
|
|
50
50
|
import { FrameRateLimiter } from "./drivers/FrameRateLimiter.js";
|
|
51
|
+
import { CanvasCoverFrameloop } from "./drivers/CanvasCoverFrameloop.js";
|
|
51
52
|
import { FpsCounter, FpsProbe } from "./diagnostics/FpsCounter.js";
|
|
52
53
|
import { ConfiguredLighting, BackdropFog } from "./render/SceneLighting.js";
|
|
53
54
|
import { IrradianceVolumeLighting } from "./render/IrradianceVolumeLighting.js";
|
|
@@ -356,6 +357,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
356
357
|
run: () => action.run(ctx),
|
|
357
358
|
}));
|
|
358
359
|
const touchScale = compact ? 0.88 : 1;
|
|
360
|
+
const canvasFrameloop = poster && posterFrozen ? "demand" : graphics.frameRateLimit > 0 && !poster ? "never" : "always";
|
|
359
361
|
// The frame carries the player's colour-vision mode; the HUD and menu text above it stay unfiltered.
|
|
360
362
|
const colorblind = useColorblindMode(settingsStore);
|
|
361
363
|
const dockMounted = !poster &&
|
|
@@ -372,7 +374,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
372
374
|
controlsActive: () => playControlsActive(ctx),
|
|
373
375
|
exitPointerLockOnDevtools: true,
|
|
374
376
|
});
|
|
375
|
-
return (_jsx(SettingsProvider, { store: settingsStore, children: _jsxs(PlayerFovProvider, { config: playable.camera, orthographic: orthographic, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: resolvePlayableAudio(playable).buses }), _jsx(SettingsRuntime, { variant: settingsVariant, surface: settingsSurface, actions: settingsActions, input: playable.game.input ?? {}, buses: resolvePlayableAudio(playable).buses, extra: settingsConfig.extra ?? [], categories: settingsConfig.categories ?? [], hide: hideCategories, fovEnabled: fovControlEnabled, graphics: playable.graphics, hideBindings: settingsConfig.hideBindings ?? [], touchStyle: coarsePointer &&
|
|
377
|
+
return (_jsx(SettingsProvider, { store: settingsStore, children: _jsxs(PlayerFovProvider, { config: playable.camera, orthographic: orthographic, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: resolvePlayableAudio(playable).buses }), _jsx(SettingsRuntime, { variant: settingsVariant, surface: settingsSurface, actions: settingsActions, input: playable.game.input ?? {}, buses: resolvePlayableAudio(playable).buses, extra: settingsConfig.extra ?? [], categories: settingsConfig.categories ?? [], hide: hideCategories, fovEnabled: fovControlEnabled, graphics: playable.graphics, postProcessing: resolvedLook.postProcessing, hideBindings: settingsConfig.hideBindings ?? [], touchStyle: coarsePointer &&
|
|
376
378
|
touchScheme !== null &&
|
|
377
379
|
(touchScheme.joystick !== null || touchScheme.buttons.length > 0), overrides: bindingOverrides, rebind: rebindAction, resetBinding: resetActionBinding, children: _jsx("div", { ref: wrapperRef, tabIndex: 0, ...(poster && posterFrozen ? { "data-poster-ready": "" } : {}), className: "relative h-full w-full bg-neutral-950 outline-none", style: {
|
|
378
380
|
"--jg-hud-dock-clearance": `${dockMounted ? touchDockClearance(touchScheme, touchScale) : 0}px`,
|
|
@@ -391,7 +393,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
391
393
|
return;
|
|
392
394
|
ktx2SupportDetected.current = true;
|
|
393
395
|
detectKtx2Support(gl);
|
|
394
|
-
}, frameloop:
|
|
396
|
+
}, frameloop: canvasFrameloop, orthographic: orthographic, camera: orthographic
|
|
395
397
|
? {
|
|
396
398
|
zoom: playable.camera?.frustum?.zoom ?? CAMERA_FRUSTUM_DEFAULTS.zoom,
|
|
397
399
|
near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
|
|
@@ -401,7 +403,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
401
403
|
fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
|
|
402
404
|
near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
|
|
403
405
|
far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
|
|
404
|
-
}, shadows: graphics.shadows ? "percentage" : false, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true, powerPreference: "high-performance" }, style: { touchAction: "none" }, children: [_jsx(TextureFiltering, { anisotropy: graphics.profile.anisotropy }), graphics.frameRateLimit > 0 && !poster ? _jsx(FrameRateLimiter, { fps: graphics.frameRateLimit }) : null, graphics.showFps ? _jsx(FpsProbe, { tally: frameTally }) : null, orthographic && playable.camera?.pixelPerfect !== undefined ? (_jsx(PixelPerfectCamera, { ...playable.camera.pixelPerfect })) : null, backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { source: playable.environmentSource, skyColor: sceneLighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: sceneLighting?.hemisphere?.groundColor ?? "#3d4a38", horizonTint: sceneLighting?.ambient?.color, sunDirection: sceneLighting?.directional?.[0]?.position, sunColor: sceneLighting?.directional?.[0]?.color ?? "#fff2d6", ...(authoredDaylight ? { daylight: daylightModulation } : {}) })) : null, sceneLighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: sceneLighting, profile: graphics.profile, ...(authoredDaylight ? { modulation: daylightModulation } : {}) })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: cinematicLook ? 0.3 : 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, authoredDaylight && effectiveSky !== undefined ? (_jsx(AuthoredDaylight, { sky: effectiveSky, clock: ctx.time, modulation: daylightModulation })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)), fogRange: backdrop?.fog })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)), fogRange: backdrop?.fog }))) : null, irradianceVolumes.length > 0 ? _jsx(IrradianceVolumeLighting, { volumes: irradianceVolumes }) : null, effectiveSky === undefined ? _jsx(BackdropFog, { fog: backdrop?.fog }) : null, _jsx(GameProvider, { context: ctx, children: _jsx(TerrainQualityContext.Provider, { value: graphics.quality, children: _jsxs(BuildingRenderBudget, { drawDistance: graphics.profile.drawDistance, visibility: playable.visibility, cullingDisabled: splitScreen, children: [_jsxs(CullingProvider, { config: playable.visibility, drawDistance: graphics.profile.drawDistance, children: [_jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson, editorLayers: playable.editorLayers }), bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxSamples === undefined ? {} : { maxSamples: bars.maxSamples }), ...(bars.tickMs === undefined ? {} : { tickMs: bars.tickMs }), ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }), ...(bars.occlude === undefined ? {} : { occlude: bars.occlude }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxSamples === undefined ? {} : { maxSamples: nameplates.maxSamples }), ...(nameplates.tickMs === undefined ? {} : { tickMs: nameplates.tickMs }), ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), occlude: nameplates.occlude, ...(nameplates.resolveName === undefined ? {} : { resolveName: nameplates.resolveName }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null] }), _jsx(WorldItems, { config: playable.worldItem }), _jsx(WorldParticles, { quality: graphics.quality, particleCap: graphics.profile.particleCap, particleBudget: graphics.profile.particleBudget, diagnostics: devtoolsEnabled }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine, anchor: playable.camera?.rig === "rts" || playable.camera?.rig === "topDown" ? "ground" : "camera" }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
|
|
406
|
+
}, shadows: graphics.shadows ? "percentage" : false, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true, powerPreference: "high-performance" }, style: { touchAction: "none" }, children: [_jsx(CanvasCoverFrameloop, { authored: canvasFrameloop }), _jsx(TextureFiltering, { anisotropy: graphics.profile.anisotropy }), graphics.frameRateLimit > 0 && !poster ? _jsx(FrameRateLimiter, { fps: graphics.frameRateLimit }) : null, graphics.showFps ? _jsx(FpsProbe, { tally: frameTally }) : null, orthographic && playable.camera?.pixelPerfect !== undefined ? (_jsx(PixelPerfectCamera, { ...playable.camera.pixelPerfect })) : null, backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { source: playable.environmentSource, skyColor: sceneLighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: sceneLighting?.hemisphere?.groundColor ?? "#3d4a38", horizonTint: sceneLighting?.ambient?.color, sunDirection: sceneLighting?.directional?.[0]?.position, sunColor: sceneLighting?.directional?.[0]?.color ?? "#fff2d6", ...(authoredDaylight ? { daylight: daylightModulation } : {}) })) : null, sceneLighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: sceneLighting, profile: graphics.profile, ...(authoredDaylight ? { modulation: daylightModulation } : {}) })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: cinematicLook ? 0.3 : 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, authoredDaylight && effectiveSky !== undefined ? (_jsx(AuthoredDaylight, { sky: effectiveSky, clock: ctx.time, modulation: daylightModulation })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)), fogRange: backdrop?.fog })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)), fogRange: backdrop?.fog }))) : null, irradianceVolumes.length > 0 ? _jsx(IrradianceVolumeLighting, { volumes: irradianceVolumes }) : null, effectiveSky === undefined ? _jsx(BackdropFog, { fog: backdrop?.fog }) : null, _jsx(GameProvider, { context: ctx, children: _jsx(TerrainQualityContext.Provider, { value: graphics.quality, children: _jsxs(BuildingRenderBudget, { drawDistance: graphics.profile.drawDistance, visibility: playable.visibility, cullingDisabled: splitScreen, children: [_jsxs(CullingProvider, { config: playable.visibility, drawDistance: graphics.profile.drawDistance, children: [_jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson, editorLayers: playable.editorLayers }), bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxSamples === undefined ? {} : { maxSamples: bars.maxSamples }), ...(bars.tickMs === undefined ? {} : { tickMs: bars.tickMs }), ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }), ...(bars.occlude === undefined ? {} : { occlude: bars.occlude }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxSamples === undefined ? {} : { maxSamples: nameplates.maxSamples }), ...(nameplates.tickMs === undefined ? {} : { tickMs: nameplates.tickMs }), ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), occlude: nameplates.occlude, ...(nameplates.resolveName === undefined ? {} : { resolveName: nameplates.resolveName }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null] }), _jsx(WorldItems, { config: playable.worldItem }), _jsx(WorldParticles, { quality: graphics.quality, particleCap: graphics.profile.particleCap, particleBudget: graphics.profile.particleBudget, diagnostics: devtoolsEnabled }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine, anchor: playable.camera?.rig === "rts" || playable.camera?.rig === "topDown" ? "ground" : "camera" }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
|
|
405
407
|
cameraDraggingRef.current = dragging;
|
|
406
408
|
} }), _jsx(PointerProbe, { service: pointerService })] }) }) }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, analogRef: analogRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: onPosterSettled, authoritativeFrameRef: authoritativeFrameRef }), _jsx(GamepadSource, { tracker: tracker, bindings: inputBindings ?? {}, analogRef: analogRef, input: ctx.input, feel: playable.gamepad, seats: localPlayers(ctx), onSeatJoin: (slot) => {
|
|
407
409
|
try {
|
|
@@ -2,15 +2,18 @@ import { useFrame, useThree } from "@react-three/fiber";
|
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
3
|
import { Quaternion, Vector3 } from "three";
|
|
4
4
|
import { useGameContext } from "@jgengine/react/provider";
|
|
5
|
+
import { createEmitterTracker } from "./emitterTracking.js";
|
|
5
6
|
import { groundFocusListener } from "./listenerAnchor.js";
|
|
6
7
|
import { createListenerVelocityTrack, stepListenerVelocity } from "./loopParams.js";
|
|
7
8
|
export function AudioListener({ engine, anchor = "camera" }) {
|
|
8
9
|
const camera = useThree((state) => state.camera);
|
|
9
10
|
const ctx = useGameContext();
|
|
10
11
|
const trackRef = useRef(createListenerVelocityTrack());
|
|
12
|
+
const scratchRef = useRef({ direction: new Vector3(), up: new Vector3(), quaternion: new Quaternion() });
|
|
11
13
|
useFrame((_, dt) => {
|
|
12
|
-
const direction =
|
|
13
|
-
|
|
14
|
+
const { direction, up, quaternion } = scratchRef.current;
|
|
15
|
+
camera.getWorldDirection(direction);
|
|
16
|
+
up.copy(camera.up).applyQuaternion(camera.getWorldQuaternion(quaternion));
|
|
14
17
|
const eye = { x: camera.position.x, y: camera.position.y, z: camera.position.z };
|
|
15
18
|
const position = anchor === "ground" ? groundFocusListener(eye, direction, ctx.world.groundHeightAt(eye.x, eye.z)) : eye;
|
|
16
19
|
engine.setListenerPose({
|
|
@@ -22,63 +25,70 @@ export function AudioListener({ engine, anchor = "camera" }) {
|
|
|
22
25
|
});
|
|
23
26
|
return null;
|
|
24
27
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Keep one loop per sounding entity/object. The tracker retains the loops and only reads the
|
|
30
|
+
* emitters it already plays, so a still world stops paying a per-frame rebuild (#2209).
|
|
31
|
+
*/
|
|
32
|
+
function useTrackedEmitters(engine, soundByKey, sources) {
|
|
33
|
+
const soundsRef = useRef(soundByKey);
|
|
34
|
+
soundsRef.current = soundByKey;
|
|
35
|
+
const stateRef = useRef(null);
|
|
36
|
+
const current = stateRef.current;
|
|
37
|
+
if (current === null || current.engine !== engine) {
|
|
38
|
+
// `sources` closes over the session's game context, which outlives the engine it was built with.
|
|
39
|
+
current?.tracker.dispose();
|
|
40
|
+
stateRef.current = {
|
|
41
|
+
engine,
|
|
42
|
+
tracker: createEmitterTracker({
|
|
43
|
+
sources,
|
|
44
|
+
sounds: () => soundsRef.current,
|
|
45
|
+
play: (soundId, position) => engine.playLoop(soundId, position),
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
28
49
|
useEffect(() => () => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
handles.current.clear();
|
|
32
|
-
lastPose.current.clear();
|
|
50
|
+
stateRef.current?.tracker.dispose();
|
|
51
|
+
stateRef.current = null;
|
|
33
52
|
}, [engine]);
|
|
34
|
-
useFrame(() =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
for (const [id, handle] of handles.current) {
|
|
63
|
-
if (!live.has(id)) {
|
|
64
|
-
handle.stop();
|
|
65
|
-
handles.current.delete(id);
|
|
66
|
-
lastPose.current.delete(id);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
});
|
|
53
|
+
useFrame(() => stateRef.current?.tracker.step());
|
|
54
|
+
}
|
|
55
|
+
function entitySources(ctx) {
|
|
56
|
+
return {
|
|
57
|
+
ids: () => ctx.scene.entity.ids(),
|
|
58
|
+
read(id, pose) {
|
|
59
|
+
const entity = ctx.scene.entity.get(id);
|
|
60
|
+
if (entity === null)
|
|
61
|
+
return false;
|
|
62
|
+
pose.key = entity.name;
|
|
63
|
+
pose.position = entity.position;
|
|
64
|
+
return true;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function objectSources(ctx) {
|
|
69
|
+
return {
|
|
70
|
+
ids: () => ctx.scene.object.ids(),
|
|
71
|
+
read(id, pose) {
|
|
72
|
+
const object = ctx.scene.object.get(id);
|
|
73
|
+
if (object === null)
|
|
74
|
+
return false;
|
|
75
|
+
pose.key = object.catalogId;
|
|
76
|
+
pose.position = object.position;
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
70
80
|
}
|
|
71
81
|
export function EntityAudioEmitters({ engine, entitySounds, }) {
|
|
72
82
|
const ctx = useGameContext();
|
|
73
|
-
|
|
83
|
+
const sourcesRef = useRef(null);
|
|
84
|
+
sourcesRef.current ??= entitySources(ctx);
|
|
85
|
+
useTrackedEmitters(engine, entitySounds, sourcesRef.current);
|
|
74
86
|
return null;
|
|
75
87
|
}
|
|
76
88
|
export function ObjectAudioEmitters({ engine, objectSounds, }) {
|
|
77
89
|
const ctx = useGameContext();
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
position: object.position,
|
|
82
|
-
})));
|
|
90
|
+
const sourcesRef = useRef(null);
|
|
91
|
+
sourcesRef.current ??= objectSources(ctx);
|
|
92
|
+
useTrackedEmitters(engine, objectSounds, sourcesRef.current);
|
|
83
93
|
return null;
|
|
84
94
|
}
|
|
@@ -7,6 +7,7 @@ import { CaptionOverlay } from "./CaptionOverlay.js";
|
|
|
7
7
|
import { createCaptionFeed } from "./captions.js";
|
|
8
8
|
import { attachEventFeedback, createEventFeedback } from "./eventFeedback.js";
|
|
9
9
|
import { installGestureResume } from "./gestureResume.js";
|
|
10
|
+
import { createHapticsRoute } from "./hapticsRoute.js";
|
|
10
11
|
/**
|
|
11
12
|
* Mounts the engine's feel layer for one game: announced events become a sound, a gamepad pulse, a
|
|
12
13
|
* camera kick and a caption line; the audio context unlocks on the first gesture; and the caption
|
|
@@ -19,22 +20,25 @@ export function FeedbackHost({ ctx, engine }) {
|
|
|
19
20
|
const feed = useMemo(() => createCaptionFeed(), []);
|
|
20
21
|
const motionScale = useRef(accessibility.reducedMotion ? 0 : 1);
|
|
21
22
|
motionScale.current = accessibility.reducedMotion ? 0 : 1;
|
|
23
|
+
const haptics = useMemo(() => createHapticsRoute((pulse) => ctx.input.haptics(ctx.player.userId).pulse("feedback", pulse, 2)), [ctx]);
|
|
22
24
|
const feedback = useMemo(() => {
|
|
23
|
-
const haptics = () => ctx.input.haptics(ctx.player.userId);
|
|
24
25
|
return createEventFeedback({
|
|
25
|
-
play: (sound) => ctx.game.audio.play(sound),
|
|
26
|
+
play: (sound, at) => ctx.game.audio.play(sound, at),
|
|
26
27
|
shake: (amount) => cameraShake(amount),
|
|
27
|
-
haptics: (pulse) =>
|
|
28
|
+
haptics: (pulse) => {
|
|
29
|
+
haptics.deliver(pulse);
|
|
30
|
+
},
|
|
28
31
|
caption: (text, cue) => feed.push(text, cue),
|
|
29
32
|
localUserId: () => ctx.player.userId,
|
|
30
33
|
localEntityId: () => ctx.player.possession.active(ctx.player.userId),
|
|
31
34
|
motionScale: () => motionScale.current,
|
|
32
35
|
}, { captions: accessibility.captions });
|
|
33
|
-
}, [ctx, feed, accessibility.captions]);
|
|
36
|
+
}, [ctx, feed, accessibility.captions, haptics]);
|
|
34
37
|
useEffect(() => {
|
|
35
38
|
feedback.retune({ captions: accessibility.captions });
|
|
36
39
|
}, [feedback, accessibility.captions]);
|
|
37
40
|
useEffect(() => attachEventFeedback(ctx.game.events, feedback), [ctx, feedback]);
|
|
41
|
+
useEffect(() => () => haptics.dispose(), [haptics]);
|
|
38
42
|
useEffect(() => installGestureResume(engine), [engine]);
|
|
39
43
|
useEffect(() => installAudioProbe(engine), [engine]);
|
|
40
44
|
useEffect(() => () => feed.clear(), [feed]);
|
|
@@ -204,6 +204,16 @@ export function createAudioEngine(config = {}) {
|
|
|
204
204
|
let listenerVelocity = { x: 0, y: 0, z: 0 };
|
|
205
205
|
const nyquist = context.sampleRate / 2;
|
|
206
206
|
const activeSpatialUpdaters = new Set();
|
|
207
|
+
// A Web Audio setter costs real time per call, so a listener pose that has not moved writes nothing (#2174).
|
|
208
|
+
const listenerWritten = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN];
|
|
209
|
+
const listenerVelocityWritten = [NaN, NaN, NaN];
|
|
210
|
+
function writeChanged(slot, index, param, value) {
|
|
211
|
+
if (slot[index] === value)
|
|
212
|
+
return false;
|
|
213
|
+
slot[index] = value;
|
|
214
|
+
setAudioParam(param, value);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
207
217
|
// Browser autoplay: until the first user gesture the context stays suspended and a played cue
|
|
208
218
|
// would vanish. Hold a bounded FIFO of ids (preallocated slots) and flush it on resume.
|
|
209
219
|
const pendingCues = Array.from({ length: MAX_PENDING_CUES }, () => ({
|
|
@@ -330,12 +340,17 @@ export function createAudioEngine(config = {}) {
|
|
|
330
340
|
let panner = null;
|
|
331
341
|
let playback = null;
|
|
332
342
|
let stopped = false;
|
|
343
|
+
let writtenCueGain = NaN;
|
|
333
344
|
function updateGain() {
|
|
334
345
|
if (cueGain === null)
|
|
335
346
|
return;
|
|
336
|
-
|
|
347
|
+
const gain = panner === null
|
|
337
348
|
? resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1)
|
|
338
349
|
: sound.gain ?? 1;
|
|
350
|
+
if (gain === writtenCueGain)
|
|
351
|
+
return;
|
|
352
|
+
writtenCueGain = gain;
|
|
353
|
+
cueGain.gain.value = gain;
|
|
339
354
|
}
|
|
340
355
|
function stop() {
|
|
341
356
|
if (stopped)
|
|
@@ -393,6 +408,15 @@ export function createAudioEngine(config = {}) {
|
|
|
393
408
|
let userGainNode = null;
|
|
394
409
|
let falloffGain = null;
|
|
395
410
|
let pannerNode = null;
|
|
411
|
+
// Last value written or scheduled per param: an unchanged emitter writes nothing (#2174).
|
|
412
|
+
let writtenFalloff = NaN;
|
|
413
|
+
let writtenPannerX = NaN;
|
|
414
|
+
let writtenPannerY = NaN;
|
|
415
|
+
let writtenPannerZ = NaN;
|
|
416
|
+
let writtenRate = NaN;
|
|
417
|
+
let writtenGain = NaN;
|
|
418
|
+
let writtenLowpass = NaN;
|
|
419
|
+
let writtenHighpass = NaN;
|
|
396
420
|
function effectiveRate() {
|
|
397
421
|
if (dopplerFactor === 0)
|
|
398
422
|
return currentRate;
|
|
@@ -400,15 +424,36 @@ export function createAudioEngine(config = {}) {
|
|
|
400
424
|
return clampLoopRate(currentRate * shift);
|
|
401
425
|
}
|
|
402
426
|
function applyRate() {
|
|
403
|
-
if (sourceNode
|
|
404
|
-
|
|
427
|
+
if (sourceNode === null)
|
|
428
|
+
return;
|
|
429
|
+
const rate = effectiveRate();
|
|
430
|
+
if (rate === writtenRate)
|
|
431
|
+
return;
|
|
432
|
+
writtenRate = rate;
|
|
433
|
+
sourceNode.playbackRate.setTargetAtTime(rate, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
405
434
|
}
|
|
406
435
|
function updateFalloff() {
|
|
407
436
|
if (falloffGain !== null && spatial === undefined) {
|
|
408
|
-
|
|
437
|
+
const gain = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
438
|
+
if (gain !== writtenFalloff) {
|
|
439
|
+
writtenFalloff = gain;
|
|
440
|
+
falloffGain.gain.value = gain;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (pannerNode !== null) {
|
|
444
|
+
if (writtenPannerX !== currentPosition.x) {
|
|
445
|
+
writtenPannerX = currentPosition.x;
|
|
446
|
+
setAudioParam(pannerNode.positionX, currentPosition.x);
|
|
447
|
+
}
|
|
448
|
+
if (writtenPannerY !== currentPosition.y) {
|
|
449
|
+
writtenPannerY = currentPosition.y;
|
|
450
|
+
setAudioParam(pannerNode.positionY, currentPosition.y);
|
|
451
|
+
}
|
|
452
|
+
if (writtenPannerZ !== currentPosition.z) {
|
|
453
|
+
writtenPannerZ = currentPosition.z;
|
|
454
|
+
setAudioParam(pannerNode.positionZ, currentPosition.z);
|
|
455
|
+
}
|
|
409
456
|
}
|
|
410
|
-
if (pannerNode !== null)
|
|
411
|
-
setPannerPosition(pannerNode, currentPosition);
|
|
412
457
|
if (dopplerFactor !== 0)
|
|
413
458
|
applyRate();
|
|
414
459
|
}
|
|
@@ -444,23 +489,28 @@ export function createAudioEngine(config = {}) {
|
|
|
444
489
|
sourceNode = src;
|
|
445
490
|
src.buffer = buffer;
|
|
446
491
|
src.loop = loop || (sound.loop ?? false);
|
|
447
|
-
|
|
492
|
+
writtenRate = effectiveRate();
|
|
493
|
+
src.playbackRate.value = writtenRate;
|
|
448
494
|
// Spatial panners own distance attenuation; the scalar stage keeps only authored gain.
|
|
449
495
|
const uGain = context.createGain();
|
|
450
496
|
userGainNode = uGain;
|
|
451
|
-
|
|
497
|
+
writtenGain = currentUserGain;
|
|
498
|
+
uGain.gain.value = writtenGain;
|
|
452
499
|
const fGain = context.createGain();
|
|
453
500
|
falloffGain = fGain;
|
|
454
|
-
|
|
501
|
+
writtenFalloff = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
502
|
+
fGain.gain.value = writtenFalloff;
|
|
455
503
|
if (loop) {
|
|
456
504
|
const lp = context.createBiquadFilter();
|
|
457
505
|
lowpassNode = lp;
|
|
458
506
|
lp.type = "lowpass";
|
|
459
|
-
|
|
507
|
+
writtenLowpass = clampLoopCutoff(currentLowpass, MAX_LOOP_CUTOFF, nyquist);
|
|
508
|
+
lp.frequency.value = writtenLowpass;
|
|
460
509
|
const hp = context.createBiquadFilter();
|
|
461
510
|
highpassNode = hp;
|
|
462
511
|
hp.type = "highpass";
|
|
463
|
-
|
|
512
|
+
writtenHighpass = clampLoopCutoff(currentHighpass, MIN_LOOP_CUTOFF, nyquist);
|
|
513
|
+
hp.frequency.value = writtenHighpass;
|
|
464
514
|
src.connect(lp);
|
|
465
515
|
lp.connect(hp);
|
|
466
516
|
hp.connect(uGain);
|
|
@@ -472,6 +522,9 @@ export function createAudioEngine(config = {}) {
|
|
|
472
522
|
if (spatial !== undefined) {
|
|
473
523
|
const panner = createPanner(context, spatial, currentPosition);
|
|
474
524
|
pannerNode = panner;
|
|
525
|
+
writtenPannerX = currentPosition.x;
|
|
526
|
+
writtenPannerY = currentPosition.y;
|
|
527
|
+
writtenPannerZ = currentPosition.z;
|
|
475
528
|
fGain.gain.value = sound.gain ?? 1;
|
|
476
529
|
fGain.connect(panner);
|
|
477
530
|
panner.connect(bus);
|
|
@@ -488,7 +541,9 @@ export function createAudioEngine(config = {}) {
|
|
|
488
541
|
setPosition(next) {
|
|
489
542
|
if (stopped)
|
|
490
543
|
return;
|
|
491
|
-
currentPosition =
|
|
544
|
+
currentPosition.x = next.x;
|
|
545
|
+
currentPosition.y = next.y;
|
|
546
|
+
currentPosition.z = next.z;
|
|
492
547
|
updateFalloff();
|
|
493
548
|
},
|
|
494
549
|
setRate(rate) {
|
|
@@ -497,21 +552,32 @@ export function createAudioEngine(config = {}) {
|
|
|
497
552
|
},
|
|
498
553
|
setLowpass(hz) {
|
|
499
554
|
currentLowpass = clampLoopCutoff(hz, MAX_LOOP_CUTOFF, nyquist);
|
|
555
|
+
if (currentLowpass === writtenLowpass)
|
|
556
|
+
return;
|
|
557
|
+
writtenLowpass = currentLowpass;
|
|
500
558
|
if (lowpassNode !== null)
|
|
501
559
|
lowpassNode.frequency.setTargetAtTime(currentLowpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
502
560
|
},
|
|
503
561
|
setHighpass(hz) {
|
|
504
562
|
currentHighpass = clampLoopCutoff(hz, MIN_LOOP_CUTOFF, nyquist);
|
|
563
|
+
if (currentHighpass === writtenHighpass)
|
|
564
|
+
return;
|
|
565
|
+
writtenHighpass = currentHighpass;
|
|
505
566
|
if (highpassNode !== null)
|
|
506
567
|
highpassNode.frequency.setTargetAtTime(currentHighpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
507
568
|
},
|
|
508
569
|
setVelocity(velocity) {
|
|
509
|
-
currentVelocity =
|
|
570
|
+
currentVelocity.x = velocity.x;
|
|
571
|
+
currentVelocity.y = velocity.y;
|
|
572
|
+
currentVelocity.z = velocity.z;
|
|
510
573
|
if (dopplerFactor !== 0)
|
|
511
574
|
applyRate();
|
|
512
575
|
},
|
|
513
576
|
setGain(gain) {
|
|
514
577
|
currentUserGain = clampLoopGain(gain);
|
|
578
|
+
if (currentUserGain === writtenGain)
|
|
579
|
+
return;
|
|
580
|
+
writtenGain = currentUserGain;
|
|
515
581
|
if (userGainNode !== null)
|
|
516
582
|
userGainNode.gain.setTargetAtTime(currentUserGain, context.currentTime, LOOP_PARAM_SMOOTH_TC);
|
|
517
583
|
},
|
|
@@ -539,18 +605,29 @@ export function createAudioEngine(config = {}) {
|
|
|
539
605
|
if (disposed)
|
|
540
606
|
return;
|
|
541
607
|
const pose = "position" in position ? position : { position, forward: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
|
|
542
|
-
listenerPosition =
|
|
543
|
-
|
|
608
|
+
listenerPosition.x = pose.position.x;
|
|
609
|
+
listenerPosition.y = pose.position.y;
|
|
610
|
+
listenerPosition.z = pose.position.z;
|
|
611
|
+
const velocity = "velocity" in pose && pose.velocity !== undefined ? pose.velocity : ZERO_VELOCITY;
|
|
612
|
+
listenerVelocity.x = velocity.x;
|
|
613
|
+
listenerVelocity.y = velocity.y;
|
|
614
|
+
listenerVelocity.z = velocity.z;
|
|
544
615
|
const listener = context.listener;
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
616
|
+
let listenerMoved = writeChanged(listenerWritten, 0, listener.positionX, pose.position.x);
|
|
617
|
+
listenerMoved = writeChanged(listenerWritten, 1, listener.positionY, pose.position.y) || listenerMoved;
|
|
618
|
+
listenerMoved = writeChanged(listenerWritten, 2, listener.positionZ, pose.position.z) || listenerMoved;
|
|
619
|
+
listenerMoved = writeChanged(listenerWritten, 3, listener.forwardX, pose.forward.x) || listenerMoved;
|
|
620
|
+
listenerMoved = writeChanged(listenerWritten, 4, listener.forwardY, pose.forward.y) || listenerMoved;
|
|
621
|
+
listenerMoved = writeChanged(listenerWritten, 5, listener.forwardZ, pose.forward.z) || listenerMoved;
|
|
622
|
+
listenerMoved = writeChanged(listenerWritten, 6, listener.upX, pose.up.x) || listenerMoved;
|
|
623
|
+
listenerMoved = writeChanged(listenerWritten, 7, listener.upY, pose.up.y) || listenerMoved;
|
|
624
|
+
listenerMoved = writeChanged(listenerWritten, 8, listener.upZ, pose.up.z) || listenerMoved;
|
|
625
|
+
// Doppler loops alone read listener velocity, so only a changed velocity needs the per-voice pass.
|
|
626
|
+
let velocityChanged = writeChanged(listenerVelocityWritten, 0, undefined, velocity.x);
|
|
627
|
+
velocityChanged = writeChanged(listenerVelocityWritten, 1, undefined, velocity.y) || velocityChanged;
|
|
628
|
+
velocityChanged = writeChanged(listenerVelocityWritten, 2, undefined, velocity.z) || velocityChanged;
|
|
629
|
+
if (!listenerMoved && !velocityChanged)
|
|
630
|
+
return;
|
|
554
631
|
for (const updateGain of activeSpatialUpdaters)
|
|
555
632
|
updateGain();
|
|
556
633
|
},
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AudioEmitterHandle, Vec3 } from "./audioEngine.js";
|
|
2
|
+
/** One emitter's live key and position, written in place so a steady frame reads every source without allocating. @internal */
|
|
3
|
+
export interface EmitterPose {
|
|
4
|
+
key: string;
|
|
5
|
+
position: readonly [number, number, number];
|
|
6
|
+
}
|
|
7
|
+
/** Read side of the emitters a game declares sounds for. @internal */
|
|
8
|
+
export interface EmitterSources {
|
|
9
|
+
/**
|
|
10
|
+
* Every candidate id. Identity changes only on spawn/despawn (`ids()` in the entity and object
|
|
11
|
+
* stores), so the tracker reconciles membership by comparing the array it last saw.
|
|
12
|
+
*/
|
|
13
|
+
ids(): readonly string[];
|
|
14
|
+
/** Fill `pose` for `id`; `false` when the source is gone. Called once per tracked emitter per frame, never per candidate. */
|
|
15
|
+
read(id: string, pose: EmitterPose): boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Retained loops for one sound map, maintained by membership and pose events instead of rebuilt per frame. @internal */
|
|
18
|
+
export interface EmitterTracker {
|
|
19
|
+
/** Advance one frame: pick up spawned/moved/despawned emitters and write nothing when nothing moved. */
|
|
20
|
+
step(): void;
|
|
21
|
+
/** Live emitters, for probes and assertions. */
|
|
22
|
+
count(): number;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Track one sound map's positional loops.
|
|
27
|
+
*
|
|
28
|
+
* Membership is reconciled only when `sources.ids()` returns a new array, and each frame then reads
|
|
29
|
+
* just the tracked emitters — so a game with hundreds of props and a handful of sounding ones pays
|
|
30
|
+
* for the handful, and a still frame allocates nothing. An emitter that gains a sounding key through
|
|
31
|
+
* a rename is picked up on the next membership change, not per frame; catalog ids never change, and a
|
|
32
|
+
* spawned entity already carries the name its sound map is keyed by.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export declare function createEmitterTracker(options: {
|
|
36
|
+
sources: EmitterSources;
|
|
37
|
+
/** Read per frame so a remount or a replaced sound map takes effect without rebuilding the tracker. */
|
|
38
|
+
sounds: () => Record<string, string> | undefined;
|
|
39
|
+
play: (soundId: string, position: Vec3) => AudioEmitterHandle | null;
|
|
40
|
+
}): EmitterTracker;
|