Render M2 glow batches as billboarded light sprites

Replace flat mesh rendering of additive/mod blend batches (blendMode >= 3)
with camera-facing point sprites using a soft radial gradient texture and
additive blending. Adds M2 particle emitter infrastructure (structs, shader,
parsing stubs) but disables emitter parsing — the assumed 476-byte struct
size is wrong for WotLK 3.3.5a, causing misaligned reads that explode RAM.
This commit is contained in:
Kelsi 2026-02-06 08:58:26 -08:00
parent b9fdc3396d
commit 88241cbddc
5 changed files with 635 additions and 7 deletions

View file

@ -48,6 +48,7 @@ struct M2AnimationTrack {
std::vector<uint32_t> timestamps; // Milliseconds
std::vector<glm::vec3> vec3Values; // For translation/scale tracks
std::vector<glm::quat> quatValues; // For rotation tracks
std::vector<float> floatValues; // For float tracks (particle emitters)
};
std::vector<SequenceKeys> sequences; // One per animation sequence
@ -129,6 +130,38 @@ struct M2Attachment {
glm::vec3 position; // Offset from bone pivot
};
// FBlock: particle lifetime curve (color/alpha/scale over particle life)
struct M2FBlock {
std::vector<float> timestamps; // Normalized 0..1
std::vector<float> floatValues; // For alpha/scale
std::vector<glm::vec3> vec3Values; // For color RGB
};
// Particle emitter definition parsed from M2
struct M2ParticleEmitter {
int32_t particleId;
uint32_t flags;
glm::vec3 position;
uint16_t bone;
uint16_t texture;
uint8_t blendingType; // 0=opaque,1=alphakey,2=alpha,4=add
uint8_t emitterType; // 1=plane,2=sphere,3=spline
M2AnimationTrack emissionSpeed;
M2AnimationTrack speedVariation;
M2AnimationTrack verticalRange;
M2AnimationTrack horizontalRange;
M2AnimationTrack gravity;
M2AnimationTrack lifespan;
M2AnimationTrack emissionRate;
M2AnimationTrack emissionAreaLength;
M2AnimationTrack emissionAreaWidth;
M2AnimationTrack deceleration;
M2FBlock particleColor; // vec3 RGB at 3 timestamps
M2FBlock particleAlpha; // float (from uint16/32767) at 3 timestamps
M2FBlock particleScale; // float (x component of vec2) at 3 timestamps
bool enabled = true;
};
// Complete M2 model structure
struct M2Model {
// Model metadata
@ -161,6 +194,9 @@ struct M2Model {
std::vector<M2Attachment> attachments;
std::vector<uint16_t> attachmentLookup; // attachment ID → index
// Particle emitters
std::vector<M2ParticleEmitter> particleEmitters;
// Flags
uint32_t globalFlags;

View file

@ -34,6 +34,8 @@ struct M2ModelGPU {
uint16_t textureAnimIndex = 0xFFFF; // 0xFFFF = no texture animation
uint16_t blendMode = 0; // 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, etc.
uint16_t materialFlags = 0; // M2 material flags (0x01=Unlit, 0x04=TwoSided, 0x10=NoDepthWrite)
glm::vec3 center = glm::vec3(0.0f); // Center of batch geometry (model space)
float glowSize = 1.0f; // Approx radius of batch geometry
};
GLuint vao = 0;
@ -66,6 +68,10 @@ struct M2ModelGPU {
bool disableAnimation = false; // Keep foliage/tree doodads visually stable
bool hasTextureAnimation = false; // True if any batch has UV animation
// Particle emitter data (kept from M2Model)
std::vector<pipeline::M2ParticleEmitter> particleEmitters;
std::vector<GLuint> particleTextures; // Resolved GL textures per emitter
// Texture transform data for UV animation
std::vector<pipeline::M2TextureTransform> textureTransforms;
std::vector<uint16_t> textureTransformLookup;
@ -74,6 +80,17 @@ struct M2ModelGPU {
bool isValid() const { return vao != 0 && indexCount > 0; }
};
/**
* A single M2 particle emitted from a particle emitter
*/
struct M2Particle {
glm::vec3 position;
glm::vec3 velocity;
float life; // current age in seconds
float maxLife; // total lifespan
int emitterIndex; // which emitter spawned this
};
/**
* Instance of an M2 model in the world
*/
@ -100,6 +117,10 @@ struct M2Instance {
float variationTimer = 0.0f; // Time until next variation attempt (ms)
bool playingVariation = false;// Currently playing a one-shot variation
// Particle emitter state
std::vector<float> emitterAccumulators; // fractional particle counter per emitter
std::vector<M2Particle> particles;
void updateModelMatrix();
};
@ -177,6 +198,11 @@ public:
*/
void renderSmokeParticles(const Camera& camera, const glm::mat4& view, const glm::mat4& projection);
/**
* Render M2 particle emitter particles (call after renderSmokeParticles())
*/
void renderM2Particles(const glm::mat4& view, const glm::mat4& proj);
/**
* Remove a specific instance by ID
* @param instanceId Instance ID returned by createInstance()
@ -260,6 +286,7 @@ private:
GLuint loadTexture(const std::string& path);
std::unordered_map<std::string, GLuint> textureCache;
GLuint whiteTexture = 0;
GLuint glowTexture = 0; // Soft radial gradient for glow sprites
// Lighting uniforms
glm::vec3 lightDir = glm::vec3(0.5f, 0.5f, 1.0f);
@ -319,6 +346,21 @@ private:
static constexpr int MAX_SMOKE_PARTICLES = 1000;
float smokeEmitAccum = 0.0f;
std::mt19937 smokeRng{42};
// M2 particle emitter system
GLuint m2ParticleShader_ = 0;
GLuint m2ParticleVAO_ = 0;
GLuint m2ParticleVBO_ = 0;
static constexpr size_t MAX_M2_PARTICLES = 4000;
std::mt19937 particleRng_{123};
float interpFloat(const pipeline::M2AnimationTrack& track, float animTime, int seqIdx,
const std::vector<pipeline::M2Sequence>& seqs,
const std::vector<uint32_t>& globalSeqDurations);
float interpFBlockFloat(const pipeline::M2FBlock& fb, float lifeRatio);
glm::vec3 interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio);
void emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt);
void updateParticles(M2Instance& inst, float dt);
};
} // namespace rendering

View file

@ -95,6 +95,19 @@ struct M2Header {
uint32_t ofsAttachments;
uint32_t nAttachmentLookup;
uint32_t ofsAttachmentLookup;
uint32_t nEvents;
uint32_t ofsEvents;
uint32_t nLights;
uint32_t ofsLights;
uint32_t nCameras;
uint32_t ofsCameras;
uint32_t nCameraLookup;
uint32_t ofsCameraLookup;
uint32_t nRibbonEmitters;
uint32_t ofsRibbonEmitters;
uint32_t nParticleEmitters;
uint32_t ofsParticleEmitters;
};
// M2 vertex structure (on-disk format)
@ -261,7 +274,7 @@ std::string readString(const std::vector<uint8_t>& data, uint32_t offset, uint32
return std::string(reinterpret_cast<const char*>(&data[offset]), length);
}
enum class TrackType { VEC3, QUAT_COMPRESSED };
enum class TrackType { VEC3, QUAT_COMPRESSED, FLOAT };
// Parse an M2 animation track from the binary data.
// The track uses an "array of arrays" layout: nTimestamps pairs of {count, offset}.
@ -307,14 +320,20 @@ void parseAnimTrack(const std::vector<uint8_t>& data,
track.sequences[i].timestamps = std::move(timestamps);
// Validate key data offset
size_t keyElementSize = (type == TrackType::VEC3) ? sizeof(float) * 3 : sizeof(int16_t) * 4;
size_t keyElementSize;
if (type == TrackType::FLOAT) keyElementSize = sizeof(float);
else if (type == TrackType::VEC3) keyElementSize = sizeof(float) * 3;
else keyElementSize = sizeof(int16_t) * 4;
if (keyOffset + keyCount * keyElementSize > data.size()) {
track.sequences[i].timestamps.clear();
continue;
}
// Read key values
if (type == TrackType::VEC3) {
if (type == TrackType::FLOAT) {
auto values = readArray<float>(data, keyOffset, keyCount);
track.sequences[i].floatValues = std::move(values);
} else if (type == TrackType::VEC3) {
// Translation/scale: float[3] per key
struct Vec3Disk { float x, y, z; };
auto values = readArray<Vec3Disk>(data, keyOffset, keyCount);
@ -347,6 +366,59 @@ void parseAnimTrack(const std::vector<uint8_t>& data,
}
}
// Parse an FBlock (particle lifetime curve) from a 20-byte on-disk header.
// FBlocks use the same layout as M2TrackDisk but timestamps/values are flat arrays.
void parseFBlock(const std::vector<uint8_t>& data, uint32_t offset,
M2FBlock& fb, int valueType) {
// valueType: 0 = color (3 bytes RGB), 1 = alpha (uint16), 2 = scale (float pair)
if (offset + 20 > data.size()) return;
M2TrackDisk disk = readValue<M2TrackDisk>(data, offset);
if (disk.nTimestamps == 0 || disk.nKeys == 0) return;
// FBlock timestamps are uint16 (not sub-arrays), stored directly
if (disk.ofsTimestamps + disk.nTimestamps * sizeof(uint16_t) > data.size()) return;
auto rawTs = readArray<uint16_t>(data, disk.ofsTimestamps, disk.nTimestamps);
uint16_t maxTs = 1;
for (auto t : rawTs) { if (t > maxTs) maxTs = t; }
fb.timestamps.reserve(rawTs.size());
for (auto t : rawTs) {
fb.timestamps.push_back(static_cast<float>(t) / static_cast<float>(maxTs));
}
uint32_t nKeys = disk.nKeys;
uint32_t ofsKeys = disk.ofsKeys;
if (valueType == 0) {
// Color: 3 bytes per key {r, g, b}
if (ofsKeys + nKeys * 3 > data.size()) return;
fb.vec3Values.reserve(nKeys);
for (uint32_t i = 0; i < nKeys; i++) {
uint8_t r = data[ofsKeys + i * 3 + 0];
uint8_t g = data[ofsKeys + i * 3 + 1];
uint8_t b = data[ofsKeys + i * 3 + 2];
fb.vec3Values.emplace_back(r / 255.0f, g / 255.0f, b / 255.0f);
}
} else if (valueType == 1) {
// Alpha: uint16 per key
if (ofsKeys + nKeys * sizeof(uint16_t) > data.size()) return;
auto rawAlpha = readArray<uint16_t>(data, ofsKeys, nKeys);
fb.floatValues.reserve(nKeys);
for (auto a : rawAlpha) {
fb.floatValues.push_back(static_cast<float>(a) / 32767.0f);
}
} else if (valueType == 2) {
// Scale: float pair {x, y} per key, store x
if (ofsKeys + nKeys * 8 > data.size()) return;
fb.floatValues.reserve(nKeys);
for (uint32_t i = 0; i < nKeys; i++) {
float x = readValue<float>(data, ofsKeys + i * 8);
fb.floatValues.push_back(x);
}
}
}
} // anonymous namespace
M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
@ -580,6 +652,18 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
model.attachmentLookup = readArray<uint16_t>(m2Data, header.ofsAttachmentLookup, header.nAttachmentLookup);
}
// Particle emitter parsing disabled.
// The assumed EMITTER_STRUCT_SIZE (476 bytes) is incorrect for WotLK 3.3.5a M2 files.
// When iterating multiple emitters, each one after the first reads from a misaligned
// offset, producing garbage M2TrackDisk headers with huge nTimestamps/nKeys counts.
// parseAnimTrack then calls readArray which allocates vectors sized by those garbage
// counts — this caused RAM usage to explode from ~1 GB to 130+ GB, consuming all
// system memory and swap.
// TODO: determine the correct emitter struct size for build 12340 and add overflow
// guards to readArray (count * sizeof(T) can wrap uint32_t, bypassing bounds checks).
(void)header.nParticleEmitters;
(void)header.ofsParticleEmitters;
static int m2LoadLogBudget = 200;
if (m2LoadLogBudget-- > 0) {
core::Logger::getInstance().debug("M2 model loaded: ", model.name);

View file

@ -439,6 +439,74 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
glBindVertexArray(0);
}
// Create M2 particle emitter shader
{
const char* particleVertSrc = R"(
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec4 aColor;
layout (location = 2) in float aSize;
uniform mat4 uView;
uniform mat4 uProjection;
out vec4 vColor;
void main() {
vec4 viewPos = uView * vec4(aPos, 1.0);
gl_Position = uProjection * viewPos;
float dist = max(-viewPos.z, 1.0);
gl_PointSize = clamp(aSize * 800.0 / dist, 1.0, 256.0);
vColor = aColor;
}
)";
const char* particleFragSrc = R"(
#version 330 core
in vec4 vColor;
uniform sampler2D uTexture;
out vec4 FragColor;
void main() {
vec4 texColor = texture(uTexture, gl_PointCoord);
FragColor = texColor * vColor;
if (FragColor.a < 0.01) discard;
}
)";
GLuint vs = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vs, 1, &particleVertSrc, nullptr);
glCompileShader(vs);
GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fs, 1, &particleFragSrc, nullptr);
glCompileShader(fs);
m2ParticleShader_ = glCreateProgram();
glAttachShader(m2ParticleShader_, vs);
glAttachShader(m2ParticleShader_, fs);
glLinkProgram(m2ParticleShader_);
glDeleteShader(vs);
glDeleteShader(fs);
// Create particle VAO/VBO: 8 floats per particle (pos3 + rgba4 + size1)
glGenVertexArrays(1, &m2ParticleVAO_);
glGenBuffers(1, &m2ParticleVBO_);
glBindVertexArray(m2ParticleVAO_);
glBindBuffer(GL_ARRAY_BUFFER, m2ParticleVBO_);
glBufferData(GL_ARRAY_BUFFER, MAX_M2_PARTICLES * 8 * sizeof(float), nullptr, GL_DYNAMIC_DRAW);
// Position (3f)
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0);
// Color (4f)
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float)));
// Size (1f)
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(7 * sizeof(float)));
glBindVertexArray(0);
}
// Create white fallback texture
uint8_t white[] = {255, 255, 255, 255};
glGenTextures(1, &whiteTexture);
@ -448,6 +516,35 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);
// Generate soft radial gradient glow texture for light sprites
{
static constexpr int SZ = 64;
std::vector<uint8_t> px(SZ * SZ * 4);
float half = SZ / 2.0f;
for (int y = 0; y < SZ; y++) {
for (int x = 0; x < SZ; x++) {
float dx = (x + 0.5f - half) / half;
float dy = (y + 0.5f - half) / half;
float r = std::sqrt(dx * dx + dy * dy);
float a = std::max(0.0f, 1.0f - r);
a = a * a; // Quadratic falloff
int idx = (y * SZ + x) * 4;
px[idx + 0] = 255;
px[idx + 1] = 255;
px[idx + 2] = 255;
px[idx + 3] = static_cast<uint8_t>(a * 255);
}
}
glGenTextures(1, &glowTexture);
glBindTexture(GL_TEXTURE_2D, glowTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SZ, SZ, 0, GL_RGBA, GL_UNSIGNED_BYTE, px.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
}
LOG_INFO("M2 renderer initialized");
return true;
}
@ -477,6 +574,10 @@ void M2Renderer::shutdown() {
glDeleteTextures(1, &whiteTexture);
whiteTexture = 0;
}
if (glowTexture != 0) {
glDeleteTextures(1, &glowTexture);
glowTexture = 0;
}
shader.reset();
@ -485,6 +586,11 @@ void M2Renderer::shutdown() {
if (smokeVBO != 0) { glDeleteBuffers(1, &smokeVBO); smokeVBO = 0; }
smokeShader.reset();
smokeParticles.clear();
// Clean up M2 particle resources
if (m2ParticleVAO_ != 0) { glDeleteVertexArrays(1, &m2ParticleVAO_); m2ParticleVAO_ = 0; }
if (m2ParticleVBO_ != 0) { glDeleteBuffers(1, &m2ParticleVBO_); m2ParticleVBO_ = 0; }
if (m2ParticleShader_ != 0) { glDeleteProgram(m2ParticleShader_); m2ParticleShader_ = 0; }
}
bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
@ -718,6 +824,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
}
}
// Particle emitter data copy disabled (parsing disabled for now)
// Copy texture transform data for UV animation
gpuModel.textureTransforms = model.textureTransforms;
gpuModel.textureTransformLookup = model.textureTransformLookup;
@ -754,6 +862,36 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
}
bgpu.texture = tex;
bgpu.hasAlpha = (tex != 0 && tex != whiteTexture);
// Compute batch center and radius for glow sprite positioning
if (bgpu.blendMode >= 3 && batch.indexCount > 0) {
glm::vec3 sum(0.0f);
uint32_t counted = 0;
for (uint32_t j = batch.indexStart; j < batch.indexStart + batch.indexCount; j++) {
if (j < model.indices.size()) {
uint16_t vi = model.indices[j];
if (vi < model.vertices.size()) {
sum += model.vertices[vi].position;
counted++;
}
}
}
if (counted > 0) {
bgpu.center = sum / static_cast<float>(counted);
float maxDist = 0.0f;
for (uint32_t j = batch.indexStart; j < batch.indexStart + batch.indexCount; j++) {
if (j < model.indices.size()) {
uint16_t vi = model.indices[j];
if (vi < model.vertices.size()) {
float d = glm::length(model.vertices[vi].position - bgpu.center);
maxDist = std::max(maxDist, d);
}
}
}
bgpu.glowSize = std::max(maxDist, 0.5f);
}
}
gpuModel.batches.push_back(bgpu);
}
} else {
@ -1122,6 +1260,12 @@ void M2Renderer::update(float deltaTime) {
}
computeBoneMatrices(model, instance);
// M2 particle emitter update — disabled for now (too expensive with many instances)
// if (!model.particleEmitters.empty()) {
// emitParticles(instance, model, deltaTime);
// updateParticles(instance, deltaTime);
// }
}
}
@ -1148,6 +1292,14 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
Frustum frustum;
frustum.extractFromMatrix(projection * view);
// Collect glow sprites from additive/mod batches for deferred rendering
struct GlowSprite {
glm::vec3 worldPos;
glm::vec4 color; // RGBA
float size;
};
std::vector<GlowSprite> glowSprites;
shader->use();
shader->setUniform("uView", view);
shader->setUniform("uProjection", projection);
@ -1249,10 +1401,19 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
for (const auto& batch : model.batches) {
if (batch.indexCount == 0) continue;
// Skip additive/mod blend batches (glow halos, particle placeholders)
// These need a particle system to render properly; as raw geometry
// they appear as visible transparent discs.
if (batch.blendMode >= 3) continue;
// Additive/mod batches (glow halos, light effects): collect as glow sprites
// instead of rendering the mesh geometry which appears as flat orange disks.
if (batch.blendMode >= 3) {
if (distSq < 120.0f * 120.0f) { // Only render glow within 120 units
glm::vec3 worldPos = glm::vec3(instance.modelMatrix * glm::vec4(batch.center, 1.0f));
GlowSprite gs;
gs.worldPos = worldPos;
gs.color = glm::vec4(1.0f, 0.75f, 0.35f, 0.85f);
gs.size = batch.glowSize * instance.scale;
glowSprites.push_back(gs);
}
continue;
}
// Compute UV offset for texture animation
glm::vec2 uvOffset(0.0f, 0.0f);
@ -1347,6 +1508,51 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
}
}
// Render glow sprites as billboarded additive point lights
if (!glowSprites.empty() && m2ParticleShader_ != 0 && m2ParticleVAO_ != 0) {
glUseProgram(m2ParticleShader_);
GLint viewLoc = glGetUniformLocation(m2ParticleShader_, "uView");
GLint projLoc = glGetUniformLocation(m2ParticleShader_, "uProjection");
GLint texLoc = glGetUniformLocation(m2ParticleShader_, "uTexture");
glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view));
glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(projection));
glUniform1i(texLoc, 0);
glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive blending
glDepthMask(GL_FALSE);
glEnable(GL_PROGRAM_POINT_SIZE);
glDisable(GL_CULL_FACE);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, glowTexture);
// Build vertex data: pos(3) + color(4) + size(1) = 8 floats per sprite
std::vector<float> glowData;
glowData.reserve(glowSprites.size() * 8);
for (const auto& gs : glowSprites) {
glowData.push_back(gs.worldPos.x);
glowData.push_back(gs.worldPos.y);
glowData.push_back(gs.worldPos.z);
glowData.push_back(gs.color.r);
glowData.push_back(gs.color.g);
glowData.push_back(gs.color.b);
glowData.push_back(gs.color.a);
glowData.push_back(gs.size);
}
glBindVertexArray(m2ParticleVAO_);
glBindBuffer(GL_ARRAY_BUFFER, m2ParticleVBO_);
size_t uploadCount = std::min(glowSprites.size(), MAX_M2_PARTICLES);
glBufferSubData(GL_ARRAY_BUFFER, 0, uploadCount * 8 * sizeof(float), glowData.data());
glDrawArrays(GL_POINTS, 0, static_cast<GLsizei>(uploadCount));
glBindVertexArray(0);
glDepthMask(GL_TRUE);
glDisable(GL_PROGRAM_POINT_SIZE);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}
// Restore state
glDisable(GL_BLEND);
glEnable(GL_CULL_FACE);
@ -1404,6 +1610,265 @@ void M2Renderer::renderShadow(GLuint shadowShaderProgram) {
glBindVertexArray(0);
}
// --- M2 Particle Emitter Helpers ---
float M2Renderer::interpFloat(const pipeline::M2AnimationTrack& track, float animTime,
int seqIdx, const std::vector<pipeline::M2Sequence>& /*seqs*/,
const std::vector<uint32_t>& globalSeqDurations) {
if (!track.hasData()) return 0.0f;
int si; float t;
resolveTrackTime(track, seqIdx, animTime, globalSeqDurations, si, t);
if (si < 0 || si >= static_cast<int>(track.sequences.size())) return 0.0f;
const auto& keys = track.sequences[si];
if (keys.timestamps.empty() || keys.floatValues.empty()) return 0.0f;
if (keys.floatValues.size() == 1) return keys.floatValues[0];
int idx = findKeyframeIndex(keys.timestamps, t);
if (idx < 0) return 0.0f;
size_t i0 = static_cast<size_t>(idx);
size_t i1 = std::min(i0 + 1, keys.floatValues.size() - 1);
if (i0 == i1) return keys.floatValues[i0];
float t0 = static_cast<float>(keys.timestamps[i0]);
float t1 = static_cast<float>(keys.timestamps[i1]);
float dur = t1 - t0;
float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f;
return glm::mix(keys.floatValues[i0], keys.floatValues[i1], frac);
}
float M2Renderer::interpFBlockFloat(const pipeline::M2FBlock& fb, float lifeRatio) {
if (fb.floatValues.empty()) return 1.0f;
if (fb.floatValues.size() == 1 || fb.timestamps.empty()) return fb.floatValues[0];
lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f);
// Find surrounding timestamps
for (size_t i = 0; i < fb.timestamps.size() - 1; i++) {
if (lifeRatio <= fb.timestamps[i + 1]) {
float t0 = fb.timestamps[i];
float t1 = fb.timestamps[i + 1];
float dur = t1 - t0;
float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f;
size_t v0 = std::min(i, fb.floatValues.size() - 1);
size_t v1 = std::min(i + 1, fb.floatValues.size() - 1);
return glm::mix(fb.floatValues[v0], fb.floatValues[v1], frac);
}
}
return fb.floatValues.back();
}
glm::vec3 M2Renderer::interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio) {
if (fb.vec3Values.empty()) return glm::vec3(1.0f);
if (fb.vec3Values.size() == 1 || fb.timestamps.empty()) return fb.vec3Values[0];
lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f);
for (size_t i = 0; i < fb.timestamps.size() - 1; i++) {
if (lifeRatio <= fb.timestamps[i + 1]) {
float t0 = fb.timestamps[i];
float t1 = fb.timestamps[i + 1];
float dur = t1 - t0;
float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f;
size_t v0 = std::min(i, fb.vec3Values.size() - 1);
size_t v1 = std::min(i + 1, fb.vec3Values.size() - 1);
return glm::mix(fb.vec3Values[v0], fb.vec3Values[v1], frac);
}
}
return fb.vec3Values.back();
}
void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt) {
if (inst.emitterAccumulators.size() != gpu.particleEmitters.size()) {
inst.emitterAccumulators.resize(gpu.particleEmitters.size(), 0.0f);
}
std::uniform_real_distribution<float> dist01(0.0f, 1.0f);
std::uniform_real_distribution<float> distN(-1.0f, 1.0f);
for (size_t ei = 0; ei < gpu.particleEmitters.size(); ei++) {
const auto& em = gpu.particleEmitters[ei];
if (!em.enabled) continue;
float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations);
float life = interpFloat(em.lifespan, inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations);
if (rate <= 0.0f || life <= 0.0f) continue;
inst.emitterAccumulators[ei] += rate * dt;
while (inst.emitterAccumulators[ei] >= 1.0f && inst.particles.size() < MAX_M2_PARTICLES) {
inst.emitterAccumulators[ei] -= 1.0f;
M2Particle p;
p.emitterIndex = static_cast<int>(ei);
p.life = 0.0f;
p.maxLife = life;
// Position: emitter position transformed by bone matrix
glm::vec3 localPos = em.position;
glm::mat4 boneXform = glm::mat4(1.0f);
if (em.bone < inst.boneMatrices.size()) {
boneXform = inst.boneMatrices[em.bone];
}
glm::vec3 worldPos = glm::vec3(inst.modelMatrix * boneXform * glm::vec4(localPos, 1.0f));
p.position = worldPos;
// Velocity: emission speed in upward direction + random spread
float speed = interpFloat(em.emissionSpeed, inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations);
float vRange = interpFloat(em.verticalRange, inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations);
float hRange = interpFloat(em.horizontalRange, inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations);
// Base direction: up in model space, transformed to world
glm::vec3 dir(0.0f, 0.0f, 1.0f);
// Add random spread
dir.x += distN(particleRng_) * hRange;
dir.y += distN(particleRng_) * hRange;
dir.z += distN(particleRng_) * vRange;
float len = glm::length(dir);
if (len > 0.001f) dir /= len;
// Transform direction by bone + model orientation (rotation only)
glm::mat3 rotMat = glm::mat3(inst.modelMatrix * boneXform);
p.velocity = rotMat * dir * speed;
inst.particles.push_back(p);
}
// Cap accumulator to avoid bursts after lag
if (inst.emitterAccumulators[ei] > 2.0f) {
inst.emitterAccumulators[ei] = 0.0f;
}
}
}
void M2Renderer::updateParticles(M2Instance& inst, float dt) {
auto it = models.find(inst.modelId);
if (it == models.end()) return;
const auto& gpu = it->second;
for (size_t i = 0; i < inst.particles.size(); ) {
auto& p = inst.particles[i];
p.life += dt;
if (p.life >= p.maxLife) {
// Swap-and-pop removal
inst.particles[i] = inst.particles.back();
inst.particles.pop_back();
continue;
}
// Apply gravity
if (p.emitterIndex >= 0 && p.emitterIndex < static_cast<int>(gpu.particleEmitters.size())) {
float grav = interpFloat(gpu.particleEmitters[p.emitterIndex].gravity,
inst.animTime, inst.currentSequenceIndex,
gpu.sequences, gpu.globalSequenceDurations);
p.velocity.z -= grav * dt;
}
p.position += p.velocity * dt;
i++;
}
}
void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj) {
if (m2ParticleShader_ == 0 || m2ParticleVAO_ == 0) return;
// Collect all particles from all instances, grouped by texture+blend
struct ParticleGroup {
GLuint texture;
uint8_t blendType;
std::vector<float> vertexData; // 8 floats per particle
};
std::unordered_map<uint64_t, ParticleGroup> groups;
size_t totalParticles = 0;
for (auto& inst : instances) {
if (inst.particles.empty()) continue;
auto it = models.find(inst.modelId);
if (it == models.end()) continue;
const auto& gpu = it->second;
for (const auto& p : inst.particles) {
if (p.emitterIndex < 0 || p.emitterIndex >= static_cast<int>(gpu.particleEmitters.size())) continue;
const auto& em = gpu.particleEmitters[p.emitterIndex];
float lifeRatio = p.life / std::max(p.maxLife, 0.001f);
glm::vec3 color = interpFBlockVec3(em.particleColor, lifeRatio);
float alpha = interpFBlockFloat(em.particleAlpha, lifeRatio);
float scale = interpFBlockFloat(em.particleScale, lifeRatio);
GLuint tex = whiteTexture;
if (p.emitterIndex < static_cast<int>(gpu.particleTextures.size())) {
tex = gpu.particleTextures[p.emitterIndex];
}
uint64_t key = (static_cast<uint64_t>(tex) << 8) | em.blendingType;
auto& group = groups[key];
group.texture = tex;
group.blendType = em.blendingType;
group.vertexData.push_back(p.position.x);
group.vertexData.push_back(p.position.y);
group.vertexData.push_back(p.position.z);
group.vertexData.push_back(color.r);
group.vertexData.push_back(color.g);
group.vertexData.push_back(color.b);
group.vertexData.push_back(alpha);
group.vertexData.push_back(scale);
totalParticles++;
}
}
if (totalParticles == 0) return;
// Set up GL state
glEnable(GL_BLEND);
glEnable(GL_DEPTH_TEST);
glDepthMask(GL_FALSE);
glEnable(GL_PROGRAM_POINT_SIZE);
glDisable(GL_CULL_FACE);
glUseProgram(m2ParticleShader_);
GLint viewLoc = glGetUniformLocation(m2ParticleShader_, "uView");
GLint projLoc = glGetUniformLocation(m2ParticleShader_, "uProjection");
GLint texLoc = glGetUniformLocation(m2ParticleShader_, "uTexture");
glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view));
glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(proj));
glUniform1i(texLoc, 0);
glActiveTexture(GL_TEXTURE0);
glBindVertexArray(m2ParticleVAO_);
for (auto& [key, group] : groups) {
if (group.vertexData.empty()) continue;
// Set blend mode
if (group.blendType == 4) {
glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive
} else {
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); // Alpha
}
glBindTexture(GL_TEXTURE_2D, group.texture);
// Upload and draw in chunks of MAX_M2_PARTICLES
size_t count = group.vertexData.size() / 8;
size_t offset = 0;
while (offset < count) {
size_t batch = std::min(count - offset, MAX_M2_PARTICLES);
glBindBuffer(GL_ARRAY_BUFFER, m2ParticleVBO_);
glBufferSubData(GL_ARRAY_BUFFER, 0, batch * 8 * sizeof(float),
&group.vertexData[offset * 8]);
glDrawArrays(GL_POINTS, 0, static_cast<GLsizei>(batch));
offset += batch;
}
}
glBindVertexArray(0);
// Restore state
glDepthMask(GL_TRUE);
glDisable(GL_PROGRAM_POINT_SIZE);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glDisable(GL_BLEND);
glEnable(GL_CULL_FACE);
}
void M2Renderer::renderSmokeParticles(const Camera& /*camera*/, const glm::mat4& view, const glm::mat4& projection) {
if (smokeParticles.empty() || !smokeShader || smokeVAO == 0) return;

View file

@ -1170,6 +1170,7 @@ void Renderer::renderWorld(game::World* world) {
auto m2Start = std::chrono::steady_clock::now();
m2Renderer->render(*camera, view, projection);
m2Renderer->renderSmokeParticles(*camera, view, projection);
m2Renderer->renderM2Particles(view, projection);
auto m2End = std::chrono::steady_clock::now();
lastM2RenderMs = std::chrono::duration<double, std::milli>(m2End - m2Start).count();
}