chore(render): delete dead ParticleBatcher chain (2026-07-24 audit review)

ParticleBatcher/ParticleEmitterRenderer/ActiveParticleEmitter (src/AcDream.App/Rendering/Wb/)
are an earlier WorldBuilder-derived particle-preview design with zero live call
sites: ParticleBatcher.Begin/AddParticle/Flush/End are only called from
ParticleEmitterRenderer.Render, which is only called from
ActiveParticleEmitter.Render, and `new ActiveParticleEmitter` has zero call
sites anywhere in the repo (verified by grep across src/tests/tools). The only
wiring was OpenGLGraphicsDevice.ParticleBatcher (property + null-init + Dispose)
and a single assignment in WbMeshAdapter.cs.

ParticleBatcher's constructor unconditionally allocated a ParticleInstance[65536]
managed array (~3.5 MiB) plus a matching GPU instance buffer, a shader, a VAO,
and 3 more GL buffers, none tracked by GpuMemoryTracker or ever drawn from.

ParticleEmitterRenderer's per-particle-type physics (Particle::Init vector-space
resolution, CalculatePosition integration) duplicates, without retail address
citations, what src/AcDream.Core/Vfx/ParticleSystem.cs already implements with
Particle::Init (0x0051c930) / Particle::Update (0x0051c290) citations and the
same ParticleType switch — the production path (ParticleSystem.cs +
AcDream.App/Rendering/ParticleRenderer.cs) supersedes it. No unique retail
knowledge is lost.

EmbeddedResourceReader.cs and the wb_particle.vert/frag shader sources are
deleted alongside it: EmbeddedResourceReader's own doc comment says it exists
solely so ParticleBatcher/ParticleEmitterRenderer can load WB-style shader
resource names, and GetEmbeddedResource had no other caller in the repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 4afedafd085a5fadd1867418d3ec71610d9d15a7)
This commit is contained in:
Erik 2026-07-24 11:35:16 +02:00
parent cf25330458
commit a4b1214e0f
8 changed files with 0 additions and 906 deletions

View file

@ -1,22 +0,0 @@
#version 330 core
in vec2 TexCoord;
in float Opacity;
in float TextureIndex;
uniform sampler2DArray uTextureArray;
out vec4 FragColor;
void main() {
// Reverting to standard non-premultiplied sampling.
vec4 color = texture(uTextureArray, vec3(TexCoord, TextureIndex));
// Standard alpha blending: SrcAlpha, OneMinusSrcAlpha.
color.a *= Opacity;
// Alpha test to discard fully transparent pixels (standard AC behavior)
if (color.a < 0.005) discard;
FragColor = color;
}

View file

@ -1,52 +0,0 @@
#version 330 core
layout (location = 0) in vec3 aPosition; // Basic quad vertex (-0.5 to 0.5)
layout (location = 1) in vec2 aTexCoord;
// Instance attributes
layout (location = 2) in vec3 iPosition;
layout (location = 3) in vec3 iScaleOpacityActive; // x=Scale, y=Opacity, z=Active
layout (location = 4) in float iTextureIndex;
layout (location = 5) in vec4 iRotation; // Quaternion
layout (location = 6) in vec2 iSize;
layout (location = 7) in float iIsBillboard;
uniform mat4 uViewProjection;
uniform vec3 uCameraUp;
uniform vec3 uCameraRight;
out vec2 TexCoord;
out float Opacity;
out float TextureIndex;
vec3 rotate_vector(vec3 v, vec4 q) {
return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);
}
void main() {
TexCoord = aTexCoord;
Opacity = iScaleOpacityActive.y;
TextureIndex = iTextureIndex;
float scale = iScaleOpacityActive.x;
vec3 worldPos;
if (iIsBillboard > 0.5) {
// Spherical billboarding - always face camera
vec3 billboardRight = uCameraRight;
vec3 billboardUp = uCameraUp;
worldPos = iPosition
+ billboardRight * aPosition.x * iSize.x * scale
+ billboardUp * aPosition.z * iSize.y * scale;
} else {
// Standard 3D rotation using quaternion
vec3 localPos = vec3(aPosition.x * iSize.x * scale,
0.0,
aPosition.z * iSize.y * scale);
worldPos = iPosition + rotate_vector(localPos, iRotation);
}
gl_Position = uViewProjection * vec4(worldPos, 1.0);
}

View file

@ -1,39 +0,0 @@
using System.Numerics;
namespace AcDream.App.Rendering.Wb {
public class ActiveParticleEmitter {
public ParticleEmitterRenderer Renderer { get; }
public uint PartIndex { get; }
public Matrix4x4 LocalOffset { get; }
// Store reference info instead of struct copy.
// ParentLandblock was ObjectLandblock (WB type) — typed as object? in Phase O-T7
// to remove the WorldBuilder project reference; no consumer reads it.
public object? ParentLandblock { get; set; }
// ParentInstanceId was WorldBuilder.Shared.Models.ObjectId? — erased to ulong? in T7;
// the field is stored but never read by any consumer in our codebase.
public ulong? ParentInstanceId { get; set; }
public ActiveParticleEmitter(ParticleEmitterRenderer renderer, uint partIndex, Matrix4x4 localOffset, object? parentLandblock = null, ulong? parentInstanceId = null) {
Renderer = renderer;
PartIndex = partIndex;
LocalOffset = localOffset;
ParentLandblock = parentLandblock;
ParentInstanceId = parentInstanceId;
}
public void Update(float deltaTime, Matrix4x4 parentTransform) {
Renderer.ParentTransform = parentTransform;
Renderer.LocalOffset = LocalOffset;
Renderer.Update(deltaTime);
}
public void Render(ParticleBatcher batcher) {
Renderer.Render(batcher);
}
public void Dispose() {
Renderer.Dispose();
}
}
}

View file

@ -1,45 +0,0 @@
using System;
using System.IO;
namespace AcDream.App.Rendering.Wb {
/// <summary>
/// Resolves WB-style shader resource names (e.g. "Shaders.Particle.vert") to
/// the corresponding file under the acdream binary's Rendering/Shaders directory.
///
/// WB embeds shaders as assembly resources under the Chorizite.OpenGLSDLBackend
/// namespace. acdream ships shaders as plain files copied to the output directory.
/// This class adapts between the two conventions so ParticleBatcher and
/// ParticleEmitterRenderer can call GetEmbeddedResource without modification.
///
/// Mapping rule: "Shaders.Foo.vert" → Rendering/Shaders/wb_foo.vert
/// (lower-case, wb_ prefix to distinguish WB-origin shaders from acdream's own)
/// </summary>
public static class EmbeddedResourceReader {
public static string GetEmbeddedResource(string filename) {
// Convert "Shaders.Particle.vert" → "wb_particle.vert"
// Strip leading "Shaders." then lowercase and prefix with wb_
string leafName;
const string shadersPrefix = "Shaders.";
if (filename.StartsWith(shadersPrefix, StringComparison.Ordinal))
{
var rest = filename.Substring(shadersPrefix.Length); // e.g. "Particle.vert"
leafName = "wb_" + rest.ToLowerInvariant(); // e.g. "wb_particle.vert"
}
else
{
leafName = "wb_" + filename.ToLowerInvariant();
}
var shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
var fullPath = Path.Combine(shadersDir, leafName);
if (!File.Exists(fullPath))
throw new InvalidOperationException(
$"WB shader not found: '{fullPath}' (mapped from resource '{filename}'). " +
$"Ensure {leafName} is in src/AcDream.App/Rendering/Shaders/ with CopyToOutputDirectory.");
return File.ReadAllText(fullPath);
}
}
}

View file

@ -82,8 +82,6 @@ namespace AcDream.App.Rendering.Wb {
public uint SharedDebugVAO { get; private set; }
public uint SharedDebugInstanceVBO { get; private set; }
public ParticleBatcher ParticleBatcher { get; internal set; } = null!;
/// <summary>OpenGL sampler object with TextureWrapMode.Repeat (for meshes with wrapping UVs).</summary>
public uint WrapSampler { get; private set; }
/// <summary>OpenGL sampler object with TextureWrapMode.ClampToEdge (for meshes without wrapping UVs).</summary>
@ -192,11 +190,6 @@ namespace AcDream.App.Rendering.Wb {
"OpenGLGraphicsDevice construction failed and its GL prefix did not cleanly roll back.",
constructionFailure);
}
// ParticleBatcher is constructed post-ctor by WbMeshAdapter (WbMeshAdapter.cs:78)
// after the adapter has wired up all dependencies. The null! here is overridden
// immediately after construction; it is not observable as null at runtime.
ParticleBatcher = null!;
}
/// <summary>
@ -760,7 +753,6 @@ namespace AcDream.App.Rendering.Wb {
ClampSampler = 0;
_sceneDataBuffer?.Dispose();
_sceneDataBuffer = null;
ParticleBatcher?.Dispose();
}
public override IUniformBuffer CreateUniformBuffer(BufferUsage usage, int size) {

View file

@ -1,240 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using Chorizite.Core.Render;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb {
[StructLayout(LayoutKind.Sequential)]
public struct ParticleInstance {
public Vector3 Position;
public Vector3 ScaleOpacityActive; // x=scale, y=opacity, z=active (1.0 or 0.0)
public float TextureIndex;
public Quaternion Rotation;
public Vector2 Size;
public float IsBillboard; // 1.0 for true, 0.0 for false
}
public struct ParticleRenderData {
public ParticleInstance Instance;
public float DistanceSq;
public ManagedGLTextureArray? Atlas;
public bool IsAdditive;
}
public unsafe class ParticleBatcher : IDisposable {
private const int MAX_PARTICLES_TOTAL = 65536;
private readonly OpenGLGraphicsDevice _graphicsDevice;
private readonly uint _vao;
private readonly uint _vbo;
private readonly uint _ibo;
private readonly uint _instanceVbo;
private readonly IShader _shader;
private readonly ResourceCleanupGroup _resources;
private readonly ParticleInstance[] _instanceData = new ParticleInstance[MAX_PARTICLES_TOTAL];
private readonly List<ParticleRenderData> _allParticles = new();
private int _currentInstanceCount = 0;
private ManagedGLTextureArray? _currentAtlas;
private bool _currentIsAdditive;
private Matrix4x4 _viewProjection;
private Vector3 _cameraUp;
private Vector3 _cameraRight;
public ParticleBatcher(OpenGLGraphicsDevice graphicsDevice) {
_graphicsDevice = graphicsDevice ?? throw new ArgumentNullException(nameof(graphicsDevice));
var gl = _graphicsDevice.GL;
var resources = new ResourceCleanupGroup();
IShader? shader = null;
uint vao = 0;
uint vbo = 0;
uint ibo = 0;
uint instanceVbo = 0;
try {
var vertSource = EmbeddedResourceReader.GetEmbeddedResource("Shaders.Particle.vert");
var fragSource = EmbeddedResourceReader.GetEmbeddedResource("Shaders.Particle.frag");
shader = _graphicsDevice.CreateShader("Particle", vertSource, fragSource);
if (shader is IDisposable disposableShader)
resources.Add("WB particle shader", disposableShader.Dispose);
// Create quad vertices - centered to match ACViewer expansion logic
float[] vertices = {
-0.5f, 0.0f, -0.5f, 0.0f, 1.0f,
0.5f, 0.0f, -0.5f, 1.0f, 1.0f,
0.5f, 0.0f, 0.5f, 1.0f, 0.0f,
-0.5f, 0.0f, 0.5f, 0.0f, 0.0f
};
ushort[] indices = { 0, 1, 2, 2, 3, 0 };
vao = CreateVertexArray(resources, gl, "WB particle VAO");
vbo = CreateBuffer(resources, gl, "WB particle vertex buffer");
ibo = CreateBuffer(resources, gl, "WB particle index buffer");
instanceVbo = CreateBuffer(resources, gl, "WB particle instance buffer");
GlResourceCommand.Execute(gl, "configure WB particle batcher", () => {
gl.BindVertexArray(vao);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
fixed (float* p = vertices) {
gl.BufferData(BufferTargetARB.ArrayBuffer, (uint)(vertices.Length * sizeof(float)), p, BufferUsageARB.StaticDraw);
}
gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ibo);
fixed (ushort* p = indices) {
gl.BufferData(BufferTargetARB.ElementArrayBuffer, (uint)(indices.Length * sizeof(ushort)), p, BufferUsageARB.StaticDraw);
}
gl.EnableVertexAttribArray(0);
gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 5 * sizeof(float), (void*)0);
gl.EnableVertexAttribArray(1);
gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 5 * sizeof(float), (void*)(3 * sizeof(float)));
gl.BindBuffer(BufferTargetARB.ArrayBuffer, instanceVbo);
gl.BufferData(BufferTargetARB.ArrayBuffer, (uint)(MAX_PARTICLES_TOTAL * Marshal.SizeOf<ParticleInstance>()), (void*)0, BufferUsageARB.DynamicDraw);
uint stride = (uint)Marshal.SizeOf<ParticleInstance>();
gl.EnableVertexAttribArray(2);
gl.VertexAttribPointer(2, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
gl.VertexAttribDivisor(2, 1);
gl.EnableVertexAttribArray(3);
gl.VertexAttribPointer(3, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
gl.VertexAttribDivisor(3, 1);
gl.EnableVertexAttribArray(4);
gl.VertexAttribPointer(4, 1, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
gl.VertexAttribDivisor(4, 1);
gl.EnableVertexAttribArray(5);
gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, stride, (void*)(7 * sizeof(float)));
gl.VertexAttribDivisor(5, 1);
gl.EnableVertexAttribArray(6);
gl.VertexAttribPointer(6, 2, VertexAttribPointerType.Float, false, stride, (void*)(11 * sizeof(float)));
gl.VertexAttribDivisor(6, 1);
gl.EnableVertexAttribArray(7);
gl.VertexAttribPointer(7, 1, VertexAttribPointerType.Float, false, stride, (void*)(13 * sizeof(float)));
gl.VertexAttribDivisor(7, 1);
gl.BindVertexArray(0);
});
shader.Bind();
shader.SetUniform("uTextureArray", 0);
shader.Unbind();
} catch (Exception constructionFailure) {
resources.RollbackConstructionAndThrow(
"ParticleBatcher construction failed and its GL prefix did not cleanly roll back.",
constructionFailure);
}
_resources = resources;
_shader = shader!;
_vao = vao;
_vbo = vbo;
_ibo = ibo;
_instanceVbo = instanceVbo;
}
private static uint CreateBuffer(
ResourceCleanupGroup resources,
GL gl,
string name) {
uint buffer = GlResourceCommand.CreateName(gl, name, gl.GenBuffer, gl.DeleteBuffer);
resources.Add(name, () => GlResourceCommand.DeleteBuffer(gl, buffer, $"delete {name} {buffer}"));
return buffer;
}
private static uint CreateVertexArray(
ResourceCleanupGroup resources,
GL gl,
string name) {
uint vao = GlResourceCommand.CreateName(gl, name, gl.GenVertexArray, gl.DeleteVertexArray);
resources.Add(name, () => GlResourceCommand.DeleteVertexArray(gl, vao, $"delete {name} {vao}"));
return vao;
}
public void Begin(Matrix4x4 viewProjection, Vector3 cameraUp, Vector3 cameraRight) {
_viewProjection = viewProjection;
_cameraUp = cameraUp;
_cameraRight = cameraRight;
_allParticles.Clear();
}
public void AddParticle(ManagedGLTextureArray? atlas, bool isAdditive, ParticleInstance instance, float distanceSq) {
_allParticles.Add(new ParticleRenderData {
Instance = instance,
DistanceSq = distanceSq,
Atlas = atlas,
IsAdditive = isAdditive
});
}
public void Flush() {
if (_allParticles.Count == 0) return;
// Sort back-to-front
_allParticles.Sort((a, b) => b.DistanceSq.CompareTo(a.DistanceSq));
var gl = _graphicsDevice.GL;
gl.BindVertexArray(_vao);
gl.DepthMask(false);
gl.Enable(EnableCap.DepthTest);
gl.Disable(EnableCap.StencilTest);
gl.Disable(EnableCap.CullFace);
gl.Disable(EnableCap.SampleAlphaToCoverage);
gl.Disable(EnableCap.SampleAlphaToOne);
gl.Enable(EnableCap.Blend);
int i = 0;
while (i < _allParticles.Count) {
var p = _allParticles[i];
_currentAtlas = p.Atlas;
_currentIsAdditive = p.IsAdditive;
_currentInstanceCount = 0;
while (i < _allParticles.Count && _allParticles[i].Atlas == _currentAtlas && _allParticles[i].IsAdditive == _currentIsAdditive) {
_instanceData[_currentInstanceCount++] = _allParticles[i].Instance;
i++;
if (_currentInstanceCount >= MAX_PARTICLES_TOTAL) break;
}
if (_currentInstanceCount > 0 && _currentAtlas != null) {
if (_currentIsAdditive) {
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
}
else {
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
}
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(GLEnum.Texture2DArray, (uint)_currentAtlas.NativePtr);
RenderStateCache.CurrentAtlas = (uint)_currentAtlas.Slot;
gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
unsafe {
fixed (ParticleInstance* pData = _instanceData) {
gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (uint)(_currentInstanceCount * Marshal.SizeOf<ParticleInstance>()), pData);
}
}
_shader.Bind();
_shader.SetUniform("uViewProjection", _viewProjection);
_shader.SetUniform("uCameraUp", _cameraUp);
_shader.SetUniform("uCameraRight", _cameraRight);
gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedShort, (void*)0, (uint)_currentInstanceCount);
}
}
gl.DepthMask(true);
_allParticles.Clear();
RenderStateCache.CurrentVAO = 0;
RenderStateCache.CurrentIBO = 0;
}
public void End() {
Flush();
}
public void Dispose() {
_resources.RetryCleanup();
}
}
}

View file

@ -1,499 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using Chorizite.Core.Lib;
using Chorizite.Core.Render;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb {
public class ParticleEmitterRenderer : IDisposable {
private const float EPSILON = 0.0002f;
private readonly OpenGLGraphicsDevice _graphicsDevice;
private readonly ObjectMeshManager _meshManager;
private readonly ParticleEmitter _emitter;
private readonly List<Particle> _particles = new();
private readonly Random _random = new();
private ObjectRenderData? _gfxRenderData;
private ObjectRenderData? _textureRenderData;
private bool _isPointSprite;
private Quaternion _planeRotation = Quaternion.Identity;
private float _emissionTimer;
private int _totalEmitted;
private float _timeRunning;
private float _deadTimer;
public bool IsActive => true; // Previews always loop
public Matrix4x4 ParentTransform { get; set; } = Matrix4x4.Identity;
public Matrix4x4 LocalOffset { get; set; } = Matrix4x4.Identity;
struct Particle {
public Vector3 WorldOffset;
public Vector3 WorldA;
public Vector3 WorldB;
public Vector3 WorldC;
public float Lifetime;
public float MaxLifetime;
public float FinalStartScale;
public float FinalFinalScale;
public float FinalStartTrans;
public float FinalFinalTrans;
public bool IsActive;
public Vector3 EmissionOrigin;
public Quaternion Orientation;
public Vector3 CalculatedPosition;
public float DistanceToCameraSq;
}
public ParticleEmitterRenderer(OpenGLGraphicsDevice graphicsDevice, ObjectMeshManager meshManager, ParticleEmitter emitter) {
_graphicsDevice = graphicsDevice;
_meshManager = meshManager;
_emitter = emitter;
if (emitter.HwGfxObjId.DataId != 0) {
_meshManager.IncrementRefCount(emitter.HwGfxObjId.DataId);
_meshManager.PrepareMeshDataAsync(emitter.HwGfxObjId.DataId, isSetup: false);
}
if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) {
_meshManager.IncrementRefCount(emitter.GfxObjId.DataId);
_meshManager.PrepareMeshDataAsync(emitter.GfxObjId.DataId, isSetup: false);
}
}
public void Update(float deltaTime) {
// Make sure textures are loaded
if (_gfxRenderData == null) {
var gfxId = _emitter.HwGfxObjId.DataId != 0 ? _emitter.HwGfxObjId.DataId : _emitter.GfxObjId.DataId;
if (gfxId != 0) {
_meshManager.PrepareMeshDataAsync(gfxId, isSetup: false);
_gfxRenderData = _meshManager.TryGetRenderData(gfxId);
}
}
if (_textureRenderData == null && _emitter.GfxObjId.DataId != 0) {
_meshManager.PrepareMeshDataAsync(_emitter.GfxObjId.DataId, isSetup: false);
_textureRenderData = _meshManager.TryGetRenderData(_emitter.GfxObjId.DataId);
}
_isPointSprite = _gfxRenderData == null;
if (_gfxRenderData != null) {
var degradeId = _gfxRenderData.DIDDegrade;
if (degradeId != 0) {
if (_meshManager.Dats.Portal.TryGet<GfxObjDegradeInfo>(degradeId, out var degrades) && degrades.Degrades.Count > 0) {
_isPointSprite = degrades.Degrades[0].DegradeMode == 2;
}
}
}
bool isPersistent = _emitter.TotalParticles == 0 && _emitter.TotalSeconds == 0;
bool isPersistentStill = isPersistent && _emitter.ParticleType == ParticleType.Still;
// 1. Update existing particles and kill immediately if expired
for (int i = _particles.Count - 1; i >= 0; i--) {
var p = _particles[i];
if (isPersistentStill) {
p.Lifetime = 0;
}
else {
p.Lifetime += deltaTime;
}
if (!isPersistentStill && p.Lifetime >= p.MaxLifetime) {
_particles.RemoveAt(i);
continue;
}
p.CalculatedPosition = CalculatePosition(ref p);
_particles[i] = p;
}
_timeRunning += deltaTime;
// 2. Emission check
bool canEmit = (isPersistent || _timeRunning < _emitter.TotalSeconds) &&
(_emitter.TotalParticles == 0 || _totalEmitted < _emitter.TotalParticles);
if (!canEmit && _particles.Count == 0) {
_deadTimer += deltaTime;
if (_deadTimer >= 1.0f) {
_timeRunning = 0;
_totalEmitted = 0;
_emissionTimer = 0;
_deadTimer = 0f;
canEmit = true;
}
} else {
_deadTimer = 0f;
}
if (canEmit) {
if (_totalEmitted == 0 && _emitter.InitialParticles > 0) {
for (int i = 0; i < _emitter.InitialParticles; i++) {
if (_particles.Count < _emitter.MaxParticles) {
Emit();
}
}
}
if (_emitter.EmitterType == EmitterType.BirthratePerSec || _emitter.EmitterType == EmitterType.Unknown) {
_emissionTimer += deltaTime;
float interval = (float)_emitter.Birthrate;
if (interval <= 0.001f) {
while (_particles.Count < Math.Max(1, _emitter.MaxParticles)) {
if (_emitter.TotalParticles > 0 && _totalEmitted >= _emitter.TotalParticles) break;
Emit();
}
} else {
while (_emissionTimer >= interval) {
if (_emitter.TotalParticles > 0 && _totalEmitted >= _emitter.TotalParticles) break;
if (_particles.Count < _emitter.MaxParticles) {
Emit();
_emissionTimer -= interval;
}
else {
// Cap timer debt if we're full
_emissionTimer = interval;
break;
}
}
}
}
}
}
private void Emit() {
var p = new Particle();
p.Lifetime = 0;
p.MaxLifetime = GetRandomLifespan();
if (p.MaxLifetime < 0.001f) p.MaxLifetime = 0.001f;
var localRandomOffset = GetRandomOffset();
var localA = GetRandomA();
var localB = GetRandomB();
var localC = GetRandomC();
var startFrame = LocalOffset * ParentTransform;
p.EmissionOrigin = startFrame.Translation;
p.WorldOffset = Vector3.Transform(localRandomOffset, startFrame) - p.EmissionOrigin;
// AC Client Logic for vector spaces (Particle::Init):
p.WorldA = localA;
p.WorldB = localB;
p.WorldC = localC;
switch (_emitter.ParticleType) {
case ParticleType.LocalVelocity: // 2
case ParticleType.ParabolicLVGA: // 3
p.WorldA = Vector3.TransformNormal(localA, startFrame);
break;
case ParticleType.ParabolicLVLA: // 8
p.WorldA = Vector3.TransformNormal(localA, startFrame);
p.WorldB = Vector3.TransformNormal(localB, startFrame);
break;
case ParticleType.ParabolicLVGAGR: // 4
p.WorldA = Vector3.TransformNormal(localA, startFrame);
p.WorldC = localC;
break;
case ParticleType.Swarm: // 5
p.WorldA = Vector3.TransformNormal(localA, startFrame);
break;
case ParticleType.Explode: // 6
// Type 6 (Explode) A and B are global
p.WorldA = localA;
p.WorldB = localB;
// Special WorldC initialization for Explode
float randA = (float)(_random.NextDouble() * 2.0 * Math.PI - Math.PI);
float randB = (float)(_random.NextDouble() * 2.0 * Math.PI - Math.PI);
float cosB = (float)Math.Cos(randB);
p.WorldC = new Vector3(
(float)(Math.Cos(randA) * localC.X * cosB),
(float)(Math.Sin(randA) * localC.Y * cosB),
(float)(Math.Sin(randB) * localC.Z)
);
if (NormalizeCheckSmall(ref p.WorldC)) p.WorldC = Vector3.Zero;
break;
case ParticleType.Implode: // 7
p.WorldOffset *= localC.X;
p.WorldC = p.WorldOffset;
break;
case ParticleType.ParabolicLVLALR: // 9
p.WorldA = Vector3.TransformNormal(localA, startFrame);
p.WorldC = Vector3.TransformNormal(localC, startFrame);
break;
case ParticleType.ParabolicGVGAGR: // 11
p.WorldC = localC;
break;
}
p.FinalStartScale = Math.Clamp(_emitter.StartScale + (float)(_random.NextDouble() * 2.0 - 1.0) * _emitter.ScaleRand, 0.1f, 10.0f);
p.FinalFinalScale = Math.Clamp(_emitter.FinalScale + (float)(_random.NextDouble() * 2.0 - 1.0) * _emitter.ScaleRand, 0.1f, 10.0f);
p.FinalStartTrans = Math.Clamp(_emitter.StartTrans + (float)(_random.NextDouble() * 2.0 - 1.0) * _emitter.TransRand, 0.0f, 1.0f);
p.FinalFinalTrans = Math.Clamp(_emitter.FinalTrans + (float)(_random.NextDouble() * 2.0 - 1.0) * _emitter.TransRand, 0.0f, 1.0f);
p.IsActive = true;
p.Orientation = Quaternion.CreateFromRotationMatrix(startFrame);
p.CalculatedPosition = CalculatePosition(ref p);
_particles.Add(p);
_totalEmitted++;
}
private float GetRandomLifespan() {
var result = (_random.NextDouble() * 2.0 - 1.0) * _emitter.LifespanRand + _emitter.Lifespan;
return (float)Math.Max(0.0, result);
}
private Vector3 GetRandomOffset() {
var rng = new Vector3(
(float)(_random.NextDouble() * 2.0 - 1.0),
(float)(_random.NextDouble() * 2.0 - 1.0),
(float)(_random.NextDouble() * 2.0 - 1.0)
);
var offsetDir = _emitter.OffsetDir;
var dot = Vector3.Dot(offsetDir, rng);
var randomAngle = rng - offsetDir * dot;
if (NormalizeCheckSmall(ref randomAngle))
return Vector3.Zero;
var magnitude = (float)(_random.NextDouble() * (_emitter.MaxOffset - _emitter.MinOffset) + _emitter.MinOffset);
return randomAngle * magnitude;
}
private Vector3 GetRandomA() {
var magnitude = (_emitter.MaxA - _emitter.MinA) * _random.NextDouble() + _emitter.MinA;
return _emitter.A * (float)magnitude;
}
private Vector3 GetRandomB() {
var magnitude = (_emitter.MaxB - _emitter.MinB) * _random.NextDouble() + _emitter.MinB;
return _emitter.B * (float)magnitude;
}
private Vector3 GetRandomC() {
var magnitude = (_emitter.MaxC - _emitter.MinC) * _random.NextDouble() + _emitter.MinC;
return _emitter.C * (float)magnitude;
}
private bool NormalizeCheckSmall(ref Vector3 v) {
var dist = v.Length();
if (dist < EPSILON)
return true;
v *= 1.0f / dist;
return false;
}
private Vector3 CalculatePosition(ref Particle p) {
float t = p.Lifetime;
Vector3 parentOrigin = _emitter.IsParentLocal ? (LocalOffset * ParentTransform).Translation : p.EmissionOrigin;
switch (_emitter.ParticleType) {
case ParticleType.Still:
return parentOrigin + p.WorldOffset;
case ParticleType.LocalVelocity:
case ParticleType.GlobalVelocity:
return parentOrigin + p.WorldOffset + (t * p.WorldA);
case ParticleType.ParabolicLVGA:
case ParticleType.ParabolicLVLA:
case ParticleType.ParabolicGVGA:
return parentOrigin + p.WorldOffset + (t * p.WorldA) + (0.5f * t * t * p.WorldB);
case ParticleType.ParabolicLVGAGR:
case ParticleType.ParabolicLVLALR:
case ParticleType.ParabolicGVGAGR:
return parentOrigin + p.WorldOffset + (t * p.WorldA) + (0.5f * t * t * p.WorldB);
case ParticleType.Swarm:
var swarmOrigin = parentOrigin + p.WorldOffset + (t * p.WorldA);
return new Vector3(
(float)Math.Cos(t * p.WorldB.X) * p.WorldC.X + swarmOrigin.X,
(float)Math.Sin(t * p.WorldB.Y) * p.WorldC.Y + swarmOrigin.Y,
(float)Math.Cos(t * p.WorldB.Z) * p.WorldC.Z + swarmOrigin.Z
);
case ParticleType.Explode:
return new Vector3(
(t * p.WorldB.X + p.WorldC.X * p.WorldA.X) * t + p.WorldOffset.X + parentOrigin.X,
(t * p.WorldB.Y + p.WorldC.Y * p.WorldA.X) * t + p.WorldOffset.Y + parentOrigin.Y,
(t * p.WorldB.Z + p.WorldC.Z * p.WorldA.X + p.WorldA.Z) * t + p.WorldOffset.Z + parentOrigin.Z
);
case ParticleType.Implode:
return ((float)Math.Cos(p.WorldA.X * t) * p.WorldC) + (t * t * p.WorldB) + parentOrigin + p.WorldOffset;
default:
return parentOrigin + p.WorldOffset + (t * p.WorldA);
}
}
public unsafe void Render(ParticleBatcher batcher) {
if (_particles.Count == 0) return;
// Decide which data to use for texturing.
// ACViewer uses HwGfxObjId for both geometry and texture.
var textureData = _gfxRenderData ?? _textureRenderData;
var cameraPos = _graphicsDevice.CurrentSceneData.CameraPosition;
// ACViewer PointSprite logic:
// Effective scale is 0.9 * BoundingBox size (1.8 * 0.5 in ACViewer shader)
// For DrawGfxObj, it uses actual scale.
float baseScale = _isPointSprite ? 0.9f : 1.0f;
Vector2 particleSize = new Vector2(1.0f, 1.0f);
Vector3 localCenter = Vector3.Zero;
_planeRotation = Quaternion.Identity;
if (_gfxRenderData != null) {
var size = _gfxRenderData.BoundingBox.Max - _gfxRenderData.BoundingBox.Min;
localCenter = (_gfxRenderData.BoundingBox.Max + _gfxRenderData.BoundingBox.Min) / 2.0f;
if (!_isPointSprite) {
if (size.Y > size.X && size.Y > size.Z) {
// Primarily in XY plane (if X is also large) or YZ plane (if Z is also large)
if (size.X > size.Z) {
// XY plane: Map shader X->X, Z->Y
particleSize.X = size.X;
particleSize.Y = size.Y;
_planeRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, -MathF.PI / 2.0f);
} else {
// YZ plane: Map shader X->Y, Z->Z
particleSize.X = size.Y;
particleSize.Y = size.Z;
_planeRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathF.PI / 2.0f);
}
} else if (size.X > size.Y && size.X > size.Z) {
// Primarily in XZ plane (normal Y) or XY plane (normal Z)
if (size.Z > size.Y) {
// XZ plane: Already matches shader
particleSize.X = size.X;
particleSize.Y = size.Z;
_planeRotation = Quaternion.Identity;
} else {
// XY plane: Map shader X->X, Z->Y
particleSize.X = size.X;
particleSize.Y = size.Y;
_planeRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, -MathF.PI / 2.0f);
}
} else {
// Primarily in XZ or YZ
if (size.X > size.Y) {
// XZ plane
particleSize.X = size.X;
particleSize.Y = size.Z;
_planeRotation = Quaternion.Identity;
} else {
// YZ plane: Map shader X->Y, Z->Z
particleSize.X = size.Y;
particleSize.Y = size.Z;
_planeRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathF.PI / 2.0f);
}
}
} else {
// Point sprite always uses XZ size
particleSize.X = size.X;
particleSize.Y = size.Z;
_planeRotation = Quaternion.Identity;
}
// If it's a unit quad, dimensions will be 1.0
if (particleSize.X < 0.001f) particleSize.X = 1.0f;
if (particleSize.Y < 0.001f) particleSize.Y = 1.0f;
}
// Update particle distances
for (int i = 0; i < _particles.Count; i++) {
var p = _particles[i];
p.DistanceToCameraSq = Vector3.DistanceSquared(p.CalculatedPosition, cameraPos);
_particles[i] = p;
}
// Prepare instance data
ManagedGLTextureArray? atlas = null;
uint textureIndex = 0;
bool isAdditive = false;
if (textureData?.Batches.Count > 0) {
var batch = textureData.Batches[0];
isAdditive = batch.IsAdditive;
textureIndex = (uint)batch.TextureIndex;
if (batch.Atlas != null && batch.Atlas.TextureArray is ManagedGLTextureArray managedTexArray) {
atlas = managedTexArray;
}
}
for (int i = 0; i < _particles.Count; i++) {
var p = _particles[i];
float lerp = Math.Clamp(p.Lifetime / p.MaxLifetime, 0f, 1f);
float currentScale = (p.FinalStartScale + (p.FinalFinalScale - p.FinalStartScale) * lerp) * baseScale;
float opacity = 1.0f - (p.FinalStartTrans + (p.FinalFinalTrans - p.FinalStartTrans) * lerp);
var pos = p.CalculatedPosition;
var orientation = p.Orientation;
if (_emitter.ParticleType == ParticleType.ParabolicLVGAGR ||
_emitter.ParticleType == ParticleType.ParabolicLVLALR ||
_emitter.ParticleType == ParticleType.ParabolicGVGAGR) {
var w = p.WorldC * (lerp * p.MaxLifetime);
var magSq = w.LengthSquared();
if (magSq > 0.00000001f) {
var mag = MathF.Sqrt(magSq);
orientation *= Quaternion.CreateFromAxisAngle(w / mag, mag);
}
}
var offset = localCenter * currentScale;
// Align particle to the BoundingBox center since we render a mathematically centered quad.
if (_isPointSprite) {
pos.Z += offset.Z; // For billboards we only shift vertically to stay upright
} else {
pos += Vector3.Transform(offset, orientation);
}
var instance = new ParticleInstance {
Position = pos,
ScaleOpacityActive = new Vector3(currentScale, opacity, 1.0f),
TextureIndex = (float)textureIndex,
Rotation = _isPointSprite ? orientation : orientation * _planeRotation,
Size = particleSize,
IsBillboard = _isPointSprite ? 1.0f : 0.0f
};
batcher.AddParticle(atlas, isAdditive, instance, p.DistanceToCameraSq);
}
}
public void Dispose() {
// Decrement reference counts that were incremented when the renderer was created/initialized
if (_emitter.HwGfxObjId.DataId != 0) {
_meshManager.ReleaseRenderData(_emitter.HwGfxObjId.DataId);
}
if (_emitter.GfxObjId.DataId != 0 && _emitter.GfxObjId.DataId != _emitter.HwGfxObjId.DataId) {
_meshManager.ReleaseRenderData(_emitter.GfxObjId.DataId);
}
}
}
}

View file

@ -145,7 +145,6 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
}
});
resources.Add("WB graphics device", graphicsDeviceRelease.Run);
graphicsDevice.ParticleBatcher = new ParticleBatcher(graphicsDevice);
// ConsoleErrorLogger surfaces WB's silently-caught exceptions
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
meshManager = new ObjectMeshManager(