diff --git a/include/pipeline/m2_loader.hpp b/include/pipeline/m2_loader.hpp index c8e74873..a2d420dc 100644 --- a/include/pipeline/m2_loader.hpp +++ b/include/pipeline/m2_loader.hpp @@ -48,6 +48,7 @@ struct M2AnimationTrack { std::vector timestamps; // Milliseconds std::vector vec3Values; // For translation/scale tracks std::vector quatValues; // For rotation tracks + std::vector floatValues; // For float tracks (particle emitters) }; std::vector 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 timestamps; // Normalized 0..1 + std::vector floatValues; // For alpha/scale + std::vector 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 attachments; std::vector attachmentLookup; // attachment ID → index + // Particle emitters + std::vector particleEmitters; + // Flags uint32_t globalFlags; diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index a5efa770..69265cbb 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -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 particleEmitters; + std::vector particleTextures; // Resolved GL textures per emitter + // Texture transform data for UV animation std::vector textureTransforms; std::vector 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 emitterAccumulators; // fractional particle counter per emitter + std::vector 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 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& seqs, + const std::vector& 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 diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index edcf58e1..37d4a08f 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -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& data, uint32_t offset, uint32 return std::string(reinterpret_cast(&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& 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(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(data, keyOffset, keyCount); @@ -347,6 +366,59 @@ void parseAnimTrack(const std::vector& 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& 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(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(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(t) / static_cast(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(data, ofsKeys, nKeys); + fb.floatValues.reserve(nKeys); + for (auto a : rawAlpha) { + fb.floatValues.push_back(static_cast(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(data, ofsKeys + i * 8); + fb.floatValues.push_back(x); + } + } +} + } // anonymous namespace M2Model M2Loader::load(const std::vector& m2Data) { @@ -580,6 +652,18 @@ M2Model M2Loader::load(const std::vector& m2Data) { model.attachmentLookup = readArray(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); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 21f3144b..76446b4e 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -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 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(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(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 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 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(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& /*seqs*/, + const std::vector& globalSeqDurations) { + if (!track.hasData()) return 0.0f; + int si; float t; + resolveTrackTime(track, seqIdx, animTime, globalSeqDurations, si, t); + if (si < 0 || si >= static_cast(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(idx); + size_t i1 = std::min(i0 + 1, keys.floatValues.size() - 1); + if (i0 == i1) return keys.floatValues[i0]; + float t0 = static_cast(keys.timestamps[i0]); + float t1 = static_cast(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 dist01(0.0f, 1.0f); + std::uniform_real_distribution 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(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(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 vertexData; // 8 floats per particle + }; + std::unordered_map 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(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(gpu.particleTextures.size())) { + tex = gpu.particleTextures[p.emitterIndex]; + } + + uint64_t key = (static_cast(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(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; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 93ea481a..7545e7ff 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -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(m2End - m2Start).count(); }