fix(ui): restore radar, retail wield switching, and protection meshes

Late-bind radar snapshots to the canonical LiveEntityRuntime maps so the retained radar survives bootstrap and session replacement instead of capturing empty sentinels.

Route paperdoll drops through the retail AutoWield blocker transaction. Move conflicting held weapons, shields, explicit jewelry destinations, and mismatched ammo to the backpack one authoritative confirmation at a time; preserve compatible arrows when switching to melee.

Render mode-1 and no-degrade particle GfxObjs as their authored modern-pipeline meshes, retain Always2D billboards, interleave both paths back-to-front, balance emitter mesh ownership, and fail safely on corrupt DAT material metadata. This restores the closed apex on Armor Self/protection effects.

Retain Studio fixture controller lifetimes, add installed-DAT and adversarial regression coverage, synchronize retail research/divergence bookkeeping, and pass all three review tracks plus the full Release suite.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 20:52:45 +02:00
parent 8d63e5c28a
commit b26f84cc69
26 changed files with 1361 additions and 141 deletions

View file

@ -2125,6 +2125,7 @@ public sealed class GameWindow : IDisposable
_liveSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
stackSplitQuantity: _stackSplitQuantity,
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
sendPutItemInContainer: (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement));
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
@ -2197,15 +2198,15 @@ public sealed class GameWindow : IDisposable
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
_visibleEntitiesByServerGuid,
LastSpawns,
() => _visibleEntitiesByServerGuid,
() => LastSpawns,
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI,
playerEntities: _entitiesByServerGuid);
playerEntities: () => _entitiesByServerGuid);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
_retailChatVm = retailChatVm;
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
@ -2290,7 +2291,6 @@ public sealed class GameWindow : IDisposable
item, container, placement, amount),
(source, target, amount) =>
_liveSession?.SendStackableMerge(source, target, amount),
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
_itemInteractionController,
_selection),
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
@ -2588,10 +2588,16 @@ public sealed class GameWindow : IDisposable
_gl, _dats, skyShader, _textureCache!, _samplerCache);
// Phase G.1 particle renderer — renders rain / snow / spell auras
// spawned into the shared ParticleSystem as billboard quads.
// Weather uses AttachLocal emitters so the rain volume follows
// the player.
_particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats);
// spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs
// retain their authored mesh; Always2D degrade modes use billboards.
// Weather uses AttachLocal emitters so its volume follows the player.
_particleRenderer = new ParticleRenderer(
_gl,
shadersDir,
_particleSystem,
_textureCache,
_dats,
_wbMeshAdapter);
// A.5 T22.5: apply radii from the already-resolved _resolvedQuality.
// _resolvedQuality was set by the quality block immediately after
@ -9460,6 +9466,7 @@ public sealed class GameWindow : IDisposable
using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload))
{
_wbMeshAdapter?.Tick();
_particleRenderer?.BeginFrame();
}
if (_frameDiag)
FrameDiagPush(_entityUploadSamples, ref _entityUploadSampleCursor,
@ -9984,7 +9991,7 @@ public sealed class GameWindow : IDisposable
_gl.Disable(EnableCap.ClipDistance0 + _cp);
if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(_particleSystem, camera, camPos,
_particleRenderer.Draw(camera, camPos,
AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene);
}
@ -10108,7 +10115,6 @@ public sealed class GameWindow : IDisposable
return;
DisableClipDistances();
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
@ -10224,7 +10230,6 @@ public sealed class GameWindow : IDisposable
{
sigSceneParticles = sigSceneParticles == "none" ? "filtered" : sigSceneParticles + "+filtered";
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
@ -10236,7 +10241,6 @@ public sealed class GameWindow : IDisposable
{
sigSceneParticles = sigSceneParticles == "none" ? "global" : sigSceneParticles + "+global";
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene);
@ -10260,7 +10264,6 @@ public sealed class GameWindow : IDisposable
// are never in the outdoor-static id set).
sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached";
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
@ -10284,7 +10287,7 @@ public sealed class GameWindow : IDisposable
_gl.Disable(EnableCap.ClipDistance0 + _cp);
if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(_particleSystem, camera, camPos,
_particleRenderer.Draw(camera, camPos,
AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene);
}
@ -11317,7 +11320,7 @@ public sealed class GameWindow : IDisposable
DisableClipDistances();
if (renderSky && _particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(_particleSystem, camera, camPos,
_particleRenderer.Draw(camera, camPos,
AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene);
EnableClipDistances();
@ -11456,7 +11459,6 @@ public sealed class GameWindow : IDisposable
&& _particleRenderer is not null)
{
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
@ -11476,7 +11478,7 @@ public sealed class GameWindow : IDisposable
_activeDayGroup, kf, environOverrideActive);
DisableClipDistances();
if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(_particleSystem, camera, camPos,
_particleRenderer.Draw(camera, camPos,
AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene);
}
else
@ -11580,7 +11582,6 @@ public sealed class GameWindow : IDisposable
// composites the pixels.
DisableClipDistances();
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
@ -11617,7 +11618,6 @@ public sealed class GameWindow : IDisposable
DisableClipDistances();
_particleRenderer.Draw(
_particleSystem,
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
@ -14452,6 +14452,7 @@ public sealed class GameWindow : IDisposable
_fadeOverlay?.Dispose();
_clipFrame?.Dispose(); // Phase U.3
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_particleRenderer?.Dispose(); // releases particle mesh refs before WB
_samplerCache?.Dispose();
_textureCache?.Dispose();
_wbMeshAdapter?.Dispose(); // Phase N.4+N.5 WB foundation (mandatory modern path)
@ -14461,7 +14462,6 @@ public sealed class GameWindow : IDisposable
_terrainModernShader?.Dispose();
_sceneLightingUbo?.Dispose();
_paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures
_particleRenderer?.Dispose();
_debugLines?.Dispose();
_textRenderer?.Dispose();
_debugFont?.Dispose();

View file

@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.Vfx;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
@ -16,6 +19,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
{
private readonly record struct BatchKey(uint TextureHandle, bool UseTexture, bool Additive);
private readonly record struct ParticleDraw(BatchKey Key, ParticleInstance Instance);
private readonly record struct MeshBatchKey(uint GfxObjId, int BatchIndex);
private readonly record struct MeshParticleDraw(
MeshBatchKey Key,
ObjectRenderBatch Batch,
MeshParticleInstance Instance);
private readonly struct ParticleInstance
{
@ -35,18 +43,44 @@ public sealed unsafe class ParticleRenderer : IDisposable
}
}
private readonly struct MeshParticleInstance
{
public readonly Matrix4x4 Model;
public readonly uint ColorArgb;
public readonly float DistanceSq;
public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq)
{
Model = model;
ColorArgb = colorArgb;
DistanceSq = distanceSq;
}
}
private readonly GL _gl;
private readonly Shader _shader;
private readonly Shader? _meshShader;
private readonly TextureCache? _textures;
private readonly DatCollection? _dats;
private readonly WbMeshAdapter? _meshAdapter;
private readonly ParticleSystem _particles;
private readonly Dictionary<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
private readonly Dictionary<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = new();
private readonly Dictionary<uint, TranslucencyKind> _meshBlendBySurface = new();
private readonly ParticleMeshReferenceTracker? _meshReferences;
private readonly HashSet<uint> _meshLoadRequestedThisFrame = new();
private readonly int _meshTextureHandleLoc = -1;
private readonly int _meshTextureLayerLoc = -1;
private readonly uint _quadVao;
private readonly uint _quadVbo;
private readonly uint _quadEbo;
private readonly uint _instanceVbo;
private readonly uint _meshVao;
private readonly uint _meshInstanceVbo;
private float[] _instanceScratch = new float[256 * 16];
private float[] _meshInstanceScratch = new float[256 * 20];
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
@ -60,15 +94,40 @@ public sealed unsafe class ParticleRenderer : IDisposable
// no call overlaps another's use of these buffers.
private readonly List<ParticleDraw> _drawListScratch = new(64);
private readonly List<ParticleInstance> _runScratch = new(64);
private readonly List<MeshParticleDraw> _meshDrawListScratch = new(64);
private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
private readonly List<ParticleSubmission> _submissionScratch = new(128);
public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null)
public ParticleRenderer(
GL gl,
string shadersDir,
ParticleSystem particles,
TextureCache? textures = null,
DatCollection? dats = null,
WbMeshAdapter? meshAdapter = null)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_textures = textures;
_dats = dats;
_meshAdapter = meshAdapter;
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
if (_meshAdapter is not null)
{
_meshReferences = new ParticleMeshReferenceTracker(
gfxObjId => _meshAdapter.IncrementRefCount(gfxObjId),
gfxObjId => _meshAdapter.DecrementRefCount(gfxObjId));
}
_shader = new Shader(_gl,
System.IO.Path.Combine(shadersDir, "particle.vert"),
System.IO.Path.Combine(shadersDir, "particle.frag"));
if (_meshAdapter?.MeshManager?.GlobalBuffer is not null)
{
_meshShader = new Shader(_gl,
System.IO.Path.Combine(shadersDir, "particle_mesh.vert"),
System.IO.Path.Combine(shadersDir, "particle_mesh.frag"));
_meshTextureHandleLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureHandle");
_meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer");
}
float[] quadVerts =
{
@ -127,67 +186,204 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.VertexAttribDivisor(5, 1);
_gl.BindVertexArray(0);
_meshVao = _gl.GenVertexArray();
_meshInstanceVbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo);
_gl.BufferData(
BufferTargetARB.ArrayBuffer,
(nuint)(256 * 20 * sizeof(float)),
(void*)0,
BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_particles.EmitterDied += OnEmitterDied;
}
/// <summary>
/// Starts one render frame. Wb point-of-use recovery is limited to one
/// request per missing GfxObj even though portal slicing may invoke Draw
/// many times during the frame.
/// </summary>
public void BeginFrame()
=> _meshLoadRequestedThisFrame.Clear();
public void Draw(
ParticleSystem particles,
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass = ParticleRenderPass.Scene,
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter = null)
{
if (particles is null || camera is null)
if (camera is null)
return;
Matrix4x4.Invert(camera.View, out var invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter);
if (draws.Count == 0)
return;
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
// draws IS _drawListScratch (see BuildDrawList) - sorting it in place
// is fine, nothing else reads it between BuildDrawList's return and
// this call.
BuildDrawLists(cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter);
if (_submissionScratch.Count > 0)
DrawOrdered(camera);
}
_shader.Use();
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
_shader.SetInt("uParticleTexture", 0);
private void DrawOrdered(ICamera camera)
{
ParticleSubmissionOrdering.Sort(_submissionScratch);
GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer;
_gl.Enable(EnableCap.DepthTest);
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
_gl.Disable(EnableCap.CullFace);
_gl.ActiveTexture(TextureUnit.Texture0);
var run = _runScratch;
for (int i = 0; i < draws.Count;)
ParticleSubmissionKind? activeKind = null;
for (int i = 0; i < _submissionScratch.Count;)
{
var key = draws[i].Key;
run.Clear();
ParticleSubmission submission = _submissionScratch[i];
if (submission.Kind == ParticleSubmissionKind.Billboard)
{
ParticleDraw draw = _drawListScratch[submission.DrawIndex];
BatchKey key = draw.Key;
if (activeKind != ParticleSubmissionKind.Billboard)
{
PrepareBillboardPipeline(camera);
activeKind = ParticleSubmissionKind.Billboard;
}
_runScratch.Clear();
do
{
_runScratch.Add(_drawListScratch[submission.DrawIndex].Instance);
i++;
if (i >= _submissionScratch.Count)
break;
submission = _submissionScratch[i];
}
while (submission.Kind == ParticleSubmissionKind.Billboard
&& _drawListScratch[submission.DrawIndex].Key == key);
_gl.BlendFunc(
BlendingFactor.SrcAlpha,
key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
_shader.SetInt("uUseTexture", key.UseTexture ? 1 : 0);
_gl.BindTexture(TextureTarget.Texture2D, key.UseTexture ? key.TextureHandle : 0);
DrawInstances(_runScratch);
continue;
}
if (_meshShader is null || global is null)
{
i++;
continue;
}
MeshParticleDraw meshDraw = _meshDrawListScratch[submission.DrawIndex];
MeshBatchKey meshKey = meshDraw.Key;
ObjectRenderBatch batch = meshDraw.Batch;
if (activeKind != ParticleSubmissionKind.Mesh)
{
PrepareMeshPipeline(camera, global);
activeKind = ParticleSubmissionKind.Mesh;
}
_meshRunScratch.Clear();
do
{
run.Add(draws[i].Instance);
_meshRunScratch.Add(_meshDrawListScratch[submission.DrawIndex].Instance);
i++;
if (i >= _submissionScratch.Count)
break;
submission = _submissionScratch[i];
}
while (i < draws.Count && draws[i].Key == key);
while (submission.Kind == ParticleSubmissionKind.Mesh
&& _meshDrawListScratch[submission.DrawIndex].Key == meshKey);
ApplyMeshCullMode(batch.CullMode);
TranslucencyKind blend = ResolveMeshBlend(batch);
_gl.BlendFunc(
BlendingFactor.SrcAlpha,
key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
_shader.SetInt("uUseTexture", key.UseTexture ? 1 : 0);
_gl.BindTexture(TextureTarget.Texture2D, key.UseTexture ? key.TextureHandle : 0);
DrawInstances(run);
blend == TranslucencyKind.InvAlpha
? BlendingFactor.OneMinusSrcAlpha
: BlendingFactor.SrcAlpha,
blend switch
{
TranslucencyKind.Additive => BlendingFactor.One,
TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha,
_ => BlendingFactor.OneMinusSrcAlpha,
});
_gl.ProgramUniform2(
_meshShader.Program,
_meshTextureHandleLoc,
(uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu),
(uint)(batch.BindlessTextureHandle >> 32));
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
UploadMeshInstances(_meshRunScratch);
_gl.DrawElementsInstancedBaseVertex(
PrimitiveType.Triangles,
(uint)batch.IndexCount,
DrawElementsType.UnsignedShort,
(void*)(batch.FirstIndex * sizeof(ushort)),
(uint)_meshRunScratch.Count,
(int)batch.BaseVertex);
}
_gl.BindTexture(TextureTarget.Texture2D, 0);
_gl.BindVertexArray(0);
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
}
private List<ParticleDraw> BuildDrawList(
ParticleSystem particles,
private void PrepareBillboardPipeline(ICamera camera)
{
_shader.Use();
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
_shader.SetInt("uParticleTexture", 0);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(_quadVao);
}
private void PrepareMeshPipeline(ICamera camera, GlobalMeshBuffer global)
{
_meshShader!.Use();
_meshShader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.BindVertexArray(_meshVao);
// GlobalMeshBuffer may grow and replace either backing buffer. Bind
// today's buffer names on every pipeline switch instead of caching
// them in this VAO at construction time.
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, global.VBO);
int vertexStride = VertexPositionNormalTexture.Size;
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, (uint)vertexStride, (void*)(6 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, global.IBO);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo);
const int instanceStride = 20 * sizeof(float);
for (uint column = 0; column < 4; column++)
{
uint location = 3 + column;
_gl.EnableVertexAttribArray(location);
_gl.VertexAttribPointer(
location,
4,
VertexAttribPointerType.Float,
false,
instanceStride,
(void*)(column * 4 * sizeof(float)));
_gl.VertexAttribDivisor(location, 1);
}
_gl.EnableVertexAttribArray(7);
_gl.VertexAttribPointer(7, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(16 * sizeof(float)));
_gl.VertexAttribDivisor(7, 1);
}
private void BuildDrawLists(
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
Vector3 cameraRight,
@ -196,7 +392,10 @@ public sealed unsafe class ParticleRenderer : IDisposable
{
var draws = _drawListScratch;
draws.Clear();
foreach (var (em, idx) in particles.EnumerateLive())
_meshDrawListScratch.Clear();
_submissionScratch.Clear();
int sequence = 0;
foreach (var (em, idx) in _particles.EnumerateLive())
{
if (!em.PresentationVisible)
continue;
@ -213,6 +412,14 @@ public sealed unsafe class ParticleRenderer : IDisposable
// ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1.
Vector3 pos = p.Position;
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
if (gfxObjId != 0
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
&& TryAppendMeshDraws(em, p, gfxObjId, distSq, ref sequence))
{
continue;
}
var gfxInfo = ResolveParticleGfxInfo(em.Desc);
uint texture = gfxInfo.TextureHandle;
bool useTexture = texture != 0;
@ -236,10 +443,60 @@ public sealed unsafe class ParticleRenderer : IDisposable
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
}
int drawIndex = draws.Count;
draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq)));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Billboard,
drawIndex,
distSq,
sequence++));
}
}
private bool TryAppendMeshDraws(
AcDream.Core.Vfx.ParticleEmitter emitter,
Particle particle,
uint gfxObjId,
float distanceSq,
ref int sequence)
{
if (_meshAdapter is null || _meshShader is null)
return true;
_meshReferences!.Register(emitter.Handle, gfxObjId);
ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(gfxObjId);
if (renderData is null)
{
if (_meshLoadRequestedThisFrame.Add(gfxObjId))
_meshAdapter.EnsureLoaded(gfxObjId);
return true;
}
return draws;
Quaternion orientation = ParticleOrientation(emitter, particle);
Matrix4x4 model = Matrix4x4.CreateScale(particle.Size)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(particle.Position);
var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq);
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
{
ObjectRenderBatch batch = renderData.Batches[batchIndex];
if (batch.IndexCount <= 0 || batch.BindlessTextureHandle == 0)
continue;
int drawIndex = _meshDrawListScratch.Count;
_meshDrawListScratch.Add(new MeshParticleDraw(
new MeshBatchKey(gfxObjId, batchIndex),
batch,
instance));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Mesh,
drawIndex,
distanceSq,
sequence++));
}
return true;
}
private void DrawInstances(List<ParticleInstance> instances)
@ -290,6 +547,120 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count);
}
private void UploadMeshInstances(List<MeshParticleInstance> instances)
{
int needed = instances.Count * 20;
if (_meshInstanceScratch.Length < needed)
_meshInstanceScratch = new float[needed + 256 * 20];
for (int i = 0; i < instances.Count; i++)
{
MeshParticleInstance instance = instances[i];
Matrix4x4 model = instance.Model;
int o = i * 20;
_meshInstanceScratch[o + 0] = model.M11;
_meshInstanceScratch[o + 1] = model.M12;
_meshInstanceScratch[o + 2] = model.M13;
_meshInstanceScratch[o + 3] = model.M14;
_meshInstanceScratch[o + 4] = model.M21;
_meshInstanceScratch[o + 5] = model.M22;
_meshInstanceScratch[o + 6] = model.M23;
_meshInstanceScratch[o + 7] = model.M24;
_meshInstanceScratch[o + 8] = model.M31;
_meshInstanceScratch[o + 9] = model.M32;
_meshInstanceScratch[o + 10] = model.M33;
_meshInstanceScratch[o + 11] = model.M34;
_meshInstanceScratch[o + 12] = model.M41;
_meshInstanceScratch[o + 13] = model.M42;
_meshInstanceScratch[o + 14] = model.M43;
_meshInstanceScratch[o + 15] = model.M44;
_meshInstanceScratch[o + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f;
_meshInstanceScratch[o + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f;
_meshInstanceScratch[o + 18] = (instance.ColorArgb & 0xFF) / 255f;
_meshInstanceScratch[o + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f;
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _meshInstanceVbo);
fixed (void* bp = _meshInstanceScratch)
{
_gl.BufferData(
BufferTargetARB.ArrayBuffer,
(nuint)(instances.Count * 20 * sizeof(float)),
bp,
BufferUsageARB.DynamicDraw);
}
}
private void ApplyMeshCullMode(CullMode mode)
{
_gl.FrontFace(FrontFaceDirection.CW);
switch (mode)
{
case CullMode.None:
_gl.Disable(EnableCap.CullFace);
break;
case CullMode.Clockwise:
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Front);
break;
case CullMode.CounterClockwise:
case CullMode.Landblock:
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
break;
}
}
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)
{
uint surfaceId = batch.Key.SurfaceId;
if (surfaceId == 0 || _dats is null)
return batch.IsAdditive ? TranslucencyKind.Additive : TranslucencyKind.AlphaBlend;
if (_meshBlendBySurface.TryGetValue(surfaceId, out TranslucencyKind blend))
return blend;
blend = RetailParticleBlendResolver.Resolve(
surfaceId,
batch.IsAdditive,
id => _dats.Get<Surface>(id),
Console.Error.WriteLine);
_meshBlendBySurface[surfaceId] = blend;
return blend;
}
private RetailParticleGeometryKind ResolveGeometryKind(uint gfxObjId)
{
if (_geometryKindByGfxObj.TryGetValue(gfxObjId, out RetailParticleGeometryKind kind))
return kind;
uint? firstDegradeMode = null;
try
{
if (_dats?.Get<GfxObj>(gfxObjId) is { } gfx
&& gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade)
&& gfx.DIDDegrade != 0
&& _dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade) is { Degrades.Count: > 0 } degrade)
{
firstDegradeMode = degrade.Degrades[0].DegradeMode;
}
}
catch (Exception ex)
{
// Missing/corrupt content must not invent a billboard. Cache the
// retail full-mesh choice and let WbMeshAdapter's normal missing-
// asset diagnostics decide whether geometry can be presented.
Console.Error.WriteLine(
$"[particle-geometry] Failed to decode GfxObj 0x{gfxObjId:X8} degrade metadata: {ex.Message}");
}
kind = RetailParticleGeometryClassifier.Classify(firstDegradeMode);
_geometryKindByGfxObj[gfxObjId] = kind;
return kind;
}
private void OnEmitterDied(int handle)
=> _meshReferences?.Release(handle);
private ParticleGfxInfo ResolveParticleGfxInfo(EmitterDesc desc)
{
if (_textures is null)
@ -456,11 +827,17 @@ public sealed unsafe class ParticleRenderer : IDisposable
public void Dispose()
{
_particles.EmitterDied -= OnEmitterDied;
_meshReferences?.Dispose();
_gl.DeleteBuffer(_quadVbo);
_gl.DeleteBuffer(_quadEbo);
_gl.DeleteBuffer(_instanceVbo);
_gl.DeleteBuffer(_meshInstanceVbo);
_gl.DeleteVertexArray(_quadVao);
_gl.DeleteVertexArray(_meshVao);
_shader.Dispose();
_meshShader?.Dispose();
}
private readonly record struct ParticleGfxInfo(

View file

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.Rendering;
internal enum ParticleSubmissionKind
{
Billboard,
Mesh,
}
internal readonly record struct ParticleSubmission(
ParticleSubmissionKind Kind,
int DrawIndex,
float DistanceSq,
int Sequence);
/// <summary>
/// Shared ordering for the two retail particle geometry paths. Transparent
/// particles are submitted back-to-front; creation/enumeration order is the
/// deterministic tiebreak so a PES chain does not shuffle at equal distance.
/// </summary>
internal static class ParticleSubmissionOrdering
{
public static void Sort(List<ParticleSubmission> submissions)
{
ArgumentNullException.ThrowIfNull(submissions);
submissions.Sort(static (left, right) =>
{
int distance = right.DistanceSq.CompareTo(left.DistanceSq);
return distance != 0
? distance
: left.Sequence.CompareTo(right.Sequence);
});
}
}
/// <summary>
/// Balances the modern mesh pipeline's reference count against one stable
/// particle-emitter handle. Registration and teardown are idempotent because
/// the renderer sees every live particle, not merely every live emitter.
/// </summary>
internal sealed class ParticleMeshReferenceTracker : IDisposable
{
private readonly Action<uint> _increment;
private readonly Action<uint> _decrement;
private readonly Dictionary<int, uint> _gfxByEmitter = new();
private bool _disposed;
public ParticleMeshReferenceTracker(Action<uint> increment, Action<uint> decrement)
{
_increment = increment ?? throw new ArgumentNullException(nameof(increment));
_decrement = decrement ?? throw new ArgumentNullException(nameof(decrement));
}
public void Register(int emitterHandle, uint gfxObjId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_gfxByEmitter.ContainsKey(emitterHandle))
return;
_gfxByEmitter.Add(emitterHandle, gfxObjId);
_increment(gfxObjId);
}
public void Release(int emitterHandle)
{
if (_disposed || !_gfxByEmitter.Remove(emitterHandle, out uint gfxObjId))
return;
_decrement(gfxObjId);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (uint gfxObjId in _gfxByEmitter.Values)
_decrement(gfxObjId);
_gfxByEmitter.Clear();
}
}

View file

@ -0,0 +1,60 @@
using System;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
/// <summary>
/// Retail particle geometry selection from <c>CPhysicsPart::Always2D</c>
/// (named retail 0x0050D8A0). A GfxObj with no degrade entry, or whose first
/// degrade has mode 1, is drawn as its authored 3D mesh. Every other first
/// degrade mode uses the camera-facing 2D presentation.
/// </summary>
public static class RetailParticleGeometryClassifier
{
public static RetailParticleGeometryKind Classify(uint? firstDegradeMode)
=> firstDegradeMode is uint mode && mode != 1u
? RetailParticleGeometryKind.Billboard
: RetailParticleGeometryKind.FullMesh;
}
public enum RetailParticleGeometryKind
{
FullMesh,
Billboard,
}
/// <summary>
/// Resolves authored particle-surface blending without allowing one malformed
/// Surface entry to abort the render frame. The fallback remains the batch's
/// already-decoded additive bit; it never fabricates geometry or a material.
/// </summary>
internal static class RetailParticleBlendResolver
{
public static TranslucencyKind Resolve(
uint surfaceId,
bool batchIsAdditive,
Func<uint, Surface?>? loadSurface,
Action<string>? diagnostic = null)
{
TranslucencyKind fallback = batchIsAdditive
? TranslucencyKind.Additive
: TranslucencyKind.AlphaBlend;
if (surfaceId == 0 || loadSurface is null)
return fallback;
try
{
Surface? surface = loadSurface(surfaceId);
return surface is null
? fallback
: TranslucencyKindExtensions.FromSurfaceType(surface.Type);
}
catch (Exception ex)
{
diagnostic?.Invoke(
$"[particle-material] Failed to decode Surface 0x{surfaceId:X8}: {ex.Message}");
return fallback;
}
}
}

View file

@ -0,0 +1,17 @@
#version 430 core
#extension GL_ARB_bindless_texture : require
in vec2 vTexCoord;
in vec4 vColor;
out vec4 fragColor;
uniform uvec2 uTextureHandle;
uniform int uTextureLayer;
void main() {
sampler2DArray tex = sampler2DArray(uTextureHandle);
vec4 color = texture(tex, vec3(vTexCoord, float(uTextureLayer))) * vColor;
if (color.a < 0.02)
discard;
fragColor = color;
}

View file

@ -0,0 +1,18 @@
#version 430 core
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
layout(location = 3) in mat4 aModel;
layout(location = 7) in vec4 aColor;
uniform mat4 uViewProjection;
out vec2 vTexCoord;
out vec4 vColor;
void main() {
vTexCoord = aTexCoord;
vColor = aColor;
gl_Position = uViewProjection * aModel * vec4(aPosition, 1.0);
}