diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 06be404a..8c7f6550 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace wowee { @@ -93,6 +94,19 @@ struct M2Instance { void updateModelMatrix(); }; +/** + * A single smoke particle emitted from a chimney or similar M2 model + */ +struct SmokeParticle { + glm::vec3 position; + glm::vec3 velocity; + float life = 0.0f; + float maxLife = 3.0f; + float size = 1.0f; + float isSpark = 0.0f; // 0 = smoke, 1 = ember/spark + uint32_t instanceId = 0; +}; + /** * M2 Model Renderer * @@ -144,6 +158,11 @@ public: */ void render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection); + /** + * Render smoke particles (call after render()) + */ + void renderSmokeParticles(const Camera& camera, const glm::mat4& view, const glm::mat4& projection); + /** * Remove a specific instance by ID * @param instanceId Instance ID returned by createInstance() @@ -258,6 +277,15 @@ private: // Collision query profiling (per frame). mutable double queryTimeMs = 0.0; mutable uint32_t queryCallCount = 0; + + // Smoke particle system + std::vector smokeParticles; + GLuint smokeVAO = 0; + GLuint smokeVBO = 0; + std::unique_ptr smokeShader; + static constexpr int MAX_SMOKE_PARTICLES = 1000; + float smokeEmitAccum = 0.0f; + std::mt19937 smokeRng{42}; }; } // namespace rendering diff --git a/src/core/window.cpp b/src/core/window.cpp index d6cab668..d62d3b17 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -31,6 +31,8 @@ bool Window::initialize() { SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, 1); + SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, 4); // Create window Uint32 flags = SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN; @@ -82,6 +84,7 @@ bool Window::initialize() { LOG_INFO("Vendor: ", glGetString(GL_VENDOR)); // Set up OpenGL defaults + glEnable(GL_MULTISAMPLE); glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LESS); glEnable(GL_CULL_FACE); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index d974d287..eba6940b 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -218,8 +218,6 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { uniform mat4 uProjection; uniform bool uUseBones; uniform mat4 uBones[128]; - uniform float uScrollSpeed; // >0 for smoke UV scroll, 0 for normal - out vec3 FragPos; out vec3 Normal; out vec2 TexCoord; @@ -241,9 +239,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { vec4 worldPos = uModel * vec4(pos, 1.0); FragPos = worldPos.xyz; Normal = mat3(uModel) * norm; - - // Scroll UV for rising smoke effect (scroll both axes for diagonal drift) - TexCoord = vec2(aTexCoord.x - uScrollSpeed, aTexCoord.y - uScrollSpeed * 0.3); + TexCoord = aTexCoord; gl_Position = uProjection * uView * worldPos; } @@ -261,7 +257,6 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { uniform bool uHasTexture; uniform bool uAlphaTest; uniform float uFadeAlpha; - uniform float uScrollSpeed; // >0 for smoke out vec4 FragColor; @@ -273,19 +268,13 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { texColor = vec4(0.6, 0.5, 0.4, 1.0); // Fallback brownish } - bool isSmoke = (uScrollSpeed > 0.0); - - // Alpha test for leaves, fences, etc. (skip for smoke) - if (uAlphaTest && !isSmoke && texColor.a < 0.5) { + // Alpha test for leaves, fences, etc. + if (uAlphaTest && texColor.a < 0.5) { discard; } // Distance fade - discard nearly invisible fragments float finalAlpha = texColor.a * uFadeAlpha; - if (isSmoke) { - // Very soft alpha so the 4-sided box mesh blends into a smooth plume - finalAlpha *= 0.25; - } if (finalAlpha < 0.02) { discard; } @@ -300,10 +289,6 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { vec3 diffuse = diff * texColor.rgb; vec3 result = ambient + diffuse; - if (isSmoke) { - // Lighten smoke color to look like wispy gray smoke - result = mix(result, vec3(0.7, 0.7, 0.72), 0.5); - } FragColor = vec4(result, finalAlpha); } )"; @@ -314,6 +299,91 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { return false; } + // Create smoke particle shader + const char* smokeVertSrc = R"( + #version 330 core + layout (location = 0) in vec3 aPos; + layout (location = 1) in float aLifeRatio; + layout (location = 2) in float aSize; + layout (location = 3) in float aIsSpark; + + uniform mat4 uView; + uniform mat4 uProjection; + uniform float uScreenHeight; + + out float vLifeRatio; + out float vIsSpark; + + void main() { + vec4 viewPos = uView * vec4(aPos, 1.0); + gl_Position = uProjection * viewPos; + float dist = -viewPos.z; + float scale = (aIsSpark > 0.5) ? 0.12 : 0.3; + gl_PointSize = clamp(aSize * (uScreenHeight * scale) / max(dist, 1.0), 2.0, 200.0); + vLifeRatio = aLifeRatio; + vIsSpark = aIsSpark; + } + )"; + + const char* smokeFragSrc = R"( + #version 330 core + in float vLifeRatio; + in float vIsSpark; + out vec4 FragColor; + + void main() { + vec2 coord = gl_PointCoord - vec2(0.5); + float dist = length(coord) * 2.0; + + if (vIsSpark > 0.5) { + // Ember/spark: bright hot dot, fades quickly + float circle = 1.0 - smoothstep(0.3, 0.8, dist); + float fade = 1.0 - smoothstep(0.0, 1.0, vLifeRatio); + float alpha = circle * fade; + vec3 color = mix(vec3(1.0, 0.6, 0.1), vec3(1.0, 0.2, 0.0), vLifeRatio); + FragColor = vec4(color, alpha); + } else { + // Smoke: soft gray circle + float circle = 1.0 - smoothstep(0.5, 1.0, dist); + float fadeIn = smoothstep(0.0, 0.1, vLifeRatio); + float fadeOut = 1.0 - smoothstep(0.4, 1.0, vLifeRatio); + float alpha = circle * fadeIn * fadeOut * 0.5; + vec3 color = mix(vec3(0.5, 0.5, 0.53), vec3(0.65, 0.65, 0.68), vLifeRatio); + FragColor = vec4(color, alpha); + } + } + )"; + + smokeShader = std::make_unique(); + if (!smokeShader->loadFromSource(smokeVertSrc, smokeFragSrc)) { + LOG_ERROR("Failed to create smoke particle shader (non-fatal)"); + smokeShader.reset(); + } + + // Create smoke particle VAO/VBO (only if shader compiled) + if (smokeShader) { + glGenVertexArrays(1, &smokeVAO); + glGenBuffers(1, &smokeVBO); + glBindVertexArray(smokeVAO); + glBindBuffer(GL_ARRAY_BUFFER, smokeVBO); + // 5 floats per particle: pos(3) + lifeRatio(1) + size(1) + // 6 floats per particle: pos(3) + lifeRatio(1) + size(1) + isSpark(1) + glBufferData(GL_ARRAY_BUFFER, MAX_SMOKE_PARTICLES * 6 * sizeof(float), nullptr, GL_DYNAMIC_DRAW); + // Position + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); + // Life ratio + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); + // Size + glEnableVertexAttribArray(2); + glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(4 * sizeof(float))); + // IsSpark + glEnableVertexAttribArray(3); + glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(5 * sizeof(float))); + glBindVertexArray(0); + } + // Create white fallback texture uint8_t white[] = {255, 255, 255, 255}; glGenTextures(1, &whiteTexture); @@ -354,6 +424,12 @@ void M2Renderer::shutdown() { } shader.reset(); + + // Clean up smoke particle resources + if (smokeVAO != 0) { glDeleteVertexArrays(1, &smokeVAO); smokeVAO = 0; } + if (smokeVBO != 0) { glDeleteBuffers(1, &smokeVBO); smokeVBO = 0; } + smokeShader.reset(); + smokeParticles.clear(); } bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { @@ -850,6 +926,71 @@ static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { void M2Renderer::update(float deltaTime) { float dtMs = deltaTime * 1000.0f; + + // --- Smoke particle spawning --- + std::uniform_real_distribution distXY(-0.4f, 0.4f); + std::uniform_real_distribution distVelXY(-0.3f, 0.3f); + std::uniform_real_distribution distVelZ(3.0f, 5.0f); + std::uniform_real_distribution distLife(4.0f, 7.0f); + std::uniform_real_distribution distDrift(-0.2f, 0.2f); + + smokeEmitAccum += deltaTime; + float emitInterval = 1.0f / 8.0f; // 8 particles per second per emitter + + for (auto& instance : instances) { + auto it = models.find(instance.modelId); + if (it == models.end()) continue; + const M2ModelGPU& model = it->second; + + if (model.isSmoke && smokeEmitAccum >= emitInterval && + static_cast(smokeParticles.size()) < MAX_SMOKE_PARTICLES) { + // Emission point: model origin in world space (model matrix already positions at chimney) + glm::vec3 emitWorld = glm::vec3(instance.modelMatrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)); + + // Occasionally spawn a spark instead of smoke (~1 in 8) + bool spark = (smokeRng() % 8 == 0); + + SmokeParticle p; + p.position = emitWorld + glm::vec3(distXY(smokeRng), distXY(smokeRng), 0.0f); + if (spark) { + p.velocity = glm::vec3(distVelXY(smokeRng) * 2.0f, distVelXY(smokeRng) * 2.0f, distVelZ(smokeRng) * 1.5f); + p.maxLife = 0.8f + static_cast(smokeRng() % 100) / 100.0f * 1.2f; // 0.8-2.0s + p.size = 0.5f; + p.isSpark = 1.0f; + } else { + p.velocity = glm::vec3(distVelXY(smokeRng), distVelXY(smokeRng), distVelZ(smokeRng)); + p.maxLife = distLife(smokeRng); + p.size = 1.0f; + p.isSpark = 0.0f; + } + p.life = 0.0f; + p.instanceId = instance.id; + smokeParticles.push_back(p); + } + } + + if (smokeEmitAccum >= emitInterval) { + smokeEmitAccum = 0.0f; + } + + // --- Update existing smoke particles --- + for (auto it = smokeParticles.begin(); it != smokeParticles.end(); ) { + it->life += deltaTime; + if (it->life >= it->maxLife) { + it = smokeParticles.erase(it); + continue; + } + it->position += it->velocity * deltaTime; + it->velocity.z *= 0.98f; // Slight deceleration + it->velocity.x += distDrift(smokeRng) * deltaTime; + it->velocity.y += distDrift(smokeRng) * deltaTime; + // Grow from 1.0 to 3.5 over lifetime + float t = it->life / it->maxLife; + it->size = 1.0f + t * 2.5f; + ++it; + } + + // --- Normal M2 animation update --- for (auto& instance : instances) { auto it = models.find(instance.modelId); if (it == models.end()) continue; @@ -955,6 +1096,9 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: const M2ModelGPU& model = it->second; if (!model.isValid()) continue; + // Skip smoke models — replaced by particle emitters + if (model.isSmoke) continue; + // Distance culling for small objects (scaled by object size) glm::vec3 toCam = instance.position - camPos; float distSq = glm::dot(toCam, toCam); @@ -988,11 +1132,6 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: shader->setUniform("uModel", instance.modelMatrix); shader->setUniform("uFadeAlpha", fadeAlpha); - // UV scroll for smoke models: pass pre-computed scroll offset - bool isSmoke = model.isSmoke; - float scrollSpeed = isSmoke ? (instance.animTime / 1000.0f * 0.15f) : 0.0f; - shader->setUniform("uScrollSpeed", scrollSpeed); - // Upload bone matrices if model has skeletal animation bool useBones = model.hasAnimation && !instance.boneMatrices.empty(); shader->setUniform("uUseBones", useBones); @@ -1001,16 +1140,11 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: shader->setUniformMatrixArray("uBones[0]", instance.boneMatrices.data(), numBones); } - // Disable depth writes for fading objects and smoke to avoid z-fighting - if (fadeAlpha < 1.0f || isSmoke) { + // Disable depth writes for fading objects to avoid z-fighting + if (fadeAlpha < 1.0f) { glDepthMask(GL_FALSE); } - // Additive blending for smoke - if (isSmoke) { - glBlendFunc(GL_SRC_ALPHA, GL_ONE); - } - glBindVertexArray(model.vao); for (const auto& batch : model.batches) { @@ -1034,12 +1168,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: glBindVertexArray(0); - // Restore blending mode after smoke - if (isSmoke) { - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - } - - if (fadeAlpha < 1.0f || isSmoke) { + if (fadeAlpha < 1.0f) { glDepthMask(GL_TRUE); } } @@ -1049,6 +1178,56 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: 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; + + // Build vertex data: pos(3) + lifeRatio(1) + size(1) + isSpark(1) per particle + std::vector data; + data.reserve(smokeParticles.size() * 6); + for (const auto& p : smokeParticles) { + data.push_back(p.position.x); + data.push_back(p.position.y); + data.push_back(p.position.z); + data.push_back(p.life / p.maxLife); + data.push_back(p.size); + data.push_back(p.isSpark); + } + + // Upload to VBO + glBindBuffer(GL_ARRAY_BUFFER, smokeVBO); + glBufferSubData(GL_ARRAY_BUFFER, 0, data.size() * sizeof(float), data.data()); + glBindBuffer(GL_ARRAY_BUFFER, 0); + + // Set GL state + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glEnable(GL_DEPTH_TEST); // Occlude behind buildings + glDepthMask(GL_FALSE); + glEnable(GL_PROGRAM_POINT_SIZE); + glDisable(GL_CULL_FACE); + + smokeShader->use(); + smokeShader->setUniform("uView", view); + smokeShader->setUniform("uProjection", projection); + + // Get viewport height for point size scaling + GLint viewport[4]; + glGetIntegerv(GL_VIEWPORT, viewport); + smokeShader->setUniform("uScreenHeight", static_cast(viewport[3])); + + glBindVertexArray(smokeVAO); + glDrawArrays(GL_POINTS, 0, static_cast(smokeParticles.size())); + glBindVertexArray(0); + + // Restore state + glEnable(GL_DEPTH_TEST); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glDepthMask(GL_TRUE); + glDisable(GL_PROGRAM_POINT_SIZE); + glDisable(GL_BLEND); + glEnable(GL_CULL_FACE); +} + void M2Renderer::removeInstance(uint32_t instanceId) { for (auto it = instances.begin(); it != instances.end(); ++it) { if (it->id == instanceId) { @@ -1069,6 +1248,8 @@ void M2Renderer::clear() { instances.clear(); spatialGrid.clear(); instanceIndexById.clear(); + smokeParticles.clear(); + smokeEmitAccum = 0.0f; } void M2Renderer::setCollisionFocus(const glm::vec3& worldPos, float radius) { diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 81cbb417..fd05681c 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -995,6 +995,7 @@ void Renderer::renderWorld(game::World* world) { if (m2Renderer && camera) { auto m2Start = std::chrono::steady_clock::now(); m2Renderer->render(*camera, view, projection); + m2Renderer->renderSmokeParticles(*camera, view, projection); auto m2End = std::chrono::steady_clock::now(); lastM2RenderMs = std::chrono::duration(m2End - m2Start).count(); }