feat(audio): Campaign A slice A5 — retail's region ambient soundscape
acdream had no ambient system: StartAmbient minted a handle and played nothing. Retail's is a weighted-accumulation + timer-queue engine, not looping voices. On every objcell change (24 m) CellManager::ChangePosition rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells each, decoding each cell's terrain word through the region file's terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of absolute deadlines drained from the frame tick, where each pop fires a one-shot and re-arms. A continuous bed (base_chance == 0) is non-positional, crossfaded by its share of the TOTAL weight, and re-fired every min_rate seconds — that rate is the author's intended loop period, and re-firing is how retail fakes a sustained bed with no looping voice, re-rolling the variant and the crossfade each time. An intermittent one keeps its authored volume, plays at a random accumulated compass bearing at min + (max-min)*t^2, and is dice-gated. Indoors is silent by design: CEnvCell's contributor is a folded ret and EnvCell carries no sound data. The Opus review caught four bugs before this landed, one fatal: - Cell offsets were built in ABSOLUTE world coordinates and differenced against the listener's STREAMED-frame position, so every one of 576 offsets came out ~32 km, every contribution was culled, and the whole feature was silent with nothing logged. Offsets are now landblock-local the way Position::get_offset builds them, and the streamed-frame position is carried separately for playback, where it belongs. - The cell's weight was added to the shared denominator once per DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by the table's entry count — enough to push a typical authored volume under the 0.03 audibility floor. - The drain used where retail's UseTime is strictly below, so a descriptor authored with a zero rate re-armed at the same instant and spun the frame forever. - Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms, so a newly audible ambient was silent for a full period after the crossing that made it audible. Also: beds now go through retail's single 16-voice priority pool rather than acdream's UI pool (retail has one pool; parking beds in the UI pool let an A4 portal cue chop one mid-wave and discarded the authored priority), and CalcDir's in-block test is XY-only, since CalcWeight includes Z on purpose and CalcDir excludes it on purpose. Two behaviours are knowingly incomplete and registered rather than guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor set) and TS-67 (contribution weight is computed in-plane). Retires TS-29. The frame-loop hook is a typed IAmbientFramePhase, not a callback — the first attempt used an Action<float> and the architecture guard ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6eaa490bb3
commit
7c4dd1ade7
13 changed files with 1984 additions and 32 deletions
|
|
@ -10,6 +10,7 @@ using AcDream.App.Rendering.Vfx;
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Runtime;
|
||||
using AcDream.App.Settings;
|
||||
using AcDream.App.Audio;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.App.World;
|
||||
|
|
@ -797,6 +798,44 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
new LiveProjectionRescueRebucketter(
|
||||
live.WorldState,
|
||||
live.LiveEntities));
|
||||
// Campaign A slice A5: retail rebuilds the ambient soundscape on every
|
||||
// objcell change (24 m) and drains its deadline queue from the frame
|
||||
// tick. The terrain words come from the loaded landblocks in the 3x3
|
||||
// ring; the listener is the local player's cell and position.
|
||||
IAmbientFramePhase? BuildAmbientFrame()
|
||||
{
|
||||
if (content.Audio?.Ambient is not { } ambient)
|
||||
return null;
|
||||
|
||||
DatReaderWriter.DBObjs.Region? region =
|
||||
content.Dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
|
||||
if (region is null)
|
||||
return null;
|
||||
|
||||
ambient.InstallRegion(region, LoadTerrainWords);
|
||||
return new AmbientFramePhase(
|
||||
ambient,
|
||||
new LocalPlayerAmbientListenerSource(d.PlayerController));
|
||||
|
||||
ushort[]? LoadTerrainWords(uint landblockId)
|
||||
{
|
||||
if (!live.WorldState.TryGetLandblock(landblockId, out LoadedLandblock? loaded)
|
||||
|| loaded?.Heightmap is not { } heightmap)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// The dat exposes terrain words as TerrainInfo; the gatherer wants
|
||||
// the raw words. Converted here rather than in Core so the ambient
|
||||
// model stays free of the dat type. This runs only on an objcell
|
||||
// change (every 24 m), nine landblocks at a time.
|
||||
var words = new ushort[heightmap.Terrain.Length];
|
||||
for (int i = 0; i < words.Length; i++)
|
||||
words[i] = (ushort)heightmap.Terrain[i];
|
||||
return words;
|
||||
}
|
||||
}
|
||||
|
||||
var liveEffectFrame = new LiveEffectFrameController(
|
||||
d.TranslucencyFades,
|
||||
content.AnimationHookFrames,
|
||||
|
|
@ -807,7 +846,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
content.ParticleSystem,
|
||||
content.ScriptRunner,
|
||||
d.UpdateClock,
|
||||
new SettingsParticleRangeSource(d.Settings));
|
||||
new SettingsParticleRangeSource(d.Settings),
|
||||
BuildAmbientFrame());
|
||||
var liveSpatialReconciler = new LiveSpatialPresentationReconciler(
|
||||
live.EntityEffects,
|
||||
live.EquippedChildren,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue