Add smoke particle emitters with ember sparks and enable 4x MSAA

Replace UV scroll workaround for chimney smoke with proper GL_POINTS
particle system. Smoke particles rise, expand, drift, and fade over
4-7 seconds. One in eight particles spawns as a bright orange/red
ember spark. Enable 4x multisample antialiasing for smoother edges
on player models, fences, and foliage.
This commit is contained in:
Kelsi 2026-02-04 14:37:32 -08:00
parent 11a4958e84
commit c9adcd3d96
4 changed files with 249 additions and 36 deletions

View file

@ -9,6 +9,7 @@
#include <vector> #include <vector>
#include <string> #include <string>
#include <optional> #include <optional>
#include <random>
namespace wowee { namespace wowee {
@ -93,6 +94,19 @@ struct M2Instance {
void updateModelMatrix(); 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 * M2 Model Renderer
* *
@ -144,6 +158,11 @@ public:
*/ */
void render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection); 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 * Remove a specific instance by ID
* @param instanceId Instance ID returned by createInstance() * @param instanceId Instance ID returned by createInstance()
@ -258,6 +277,15 @@ private:
// Collision query profiling (per frame). // Collision query profiling (per frame).
mutable double queryTimeMs = 0.0; mutable double queryTimeMs = 0.0;
mutable uint32_t queryCallCount = 0; mutable uint32_t queryCallCount = 0;
// Smoke particle system
std::vector<SmokeParticle> smokeParticles;
GLuint smokeVAO = 0;
GLuint smokeVBO = 0;
std::unique_ptr<Shader> smokeShader;
static constexpr int MAX_SMOKE_PARTICLES = 1000;
float smokeEmitAccum = 0.0f;
std::mt19937 smokeRng{42};
}; };
} // namespace rendering } // namespace rendering

View file

@ -31,6 +31,8 @@ bool Window::initialize() {
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, 1);
SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, 4);
// Create window // Create window
Uint32 flags = SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN; Uint32 flags = SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN;
@ -82,6 +84,7 @@ bool Window::initialize() {
LOG_INFO("Vendor: ", glGetString(GL_VENDOR)); LOG_INFO("Vendor: ", glGetString(GL_VENDOR));
// Set up OpenGL defaults // Set up OpenGL defaults
glEnable(GL_MULTISAMPLE);
glEnable(GL_DEPTH_TEST); glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS); glDepthFunc(GL_LESS);
glEnable(GL_CULL_FACE); glEnable(GL_CULL_FACE);

View file

@ -218,8 +218,6 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
uniform mat4 uProjection; uniform mat4 uProjection;
uniform bool uUseBones; uniform bool uUseBones;
uniform mat4 uBones[128]; uniform mat4 uBones[128];
uniform float uScrollSpeed; // >0 for smoke UV scroll, 0 for normal
out vec3 FragPos; out vec3 FragPos;
out vec3 Normal; out vec3 Normal;
out vec2 TexCoord; out vec2 TexCoord;
@ -241,9 +239,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
vec4 worldPos = uModel * vec4(pos, 1.0); vec4 worldPos = uModel * vec4(pos, 1.0);
FragPos = worldPos.xyz; FragPos = worldPos.xyz;
Normal = mat3(uModel) * norm; Normal = mat3(uModel) * norm;
TexCoord = aTexCoord;
// Scroll UV for rising smoke effect (scroll both axes for diagonal drift)
TexCoord = vec2(aTexCoord.x - uScrollSpeed, aTexCoord.y - uScrollSpeed * 0.3);
gl_Position = uProjection * uView * worldPos; gl_Position = uProjection * uView * worldPos;
} }
@ -261,7 +257,6 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
uniform bool uHasTexture; uniform bool uHasTexture;
uniform bool uAlphaTest; uniform bool uAlphaTest;
uniform float uFadeAlpha; uniform float uFadeAlpha;
uniform float uScrollSpeed; // >0 for smoke
out vec4 FragColor; 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 texColor = vec4(0.6, 0.5, 0.4, 1.0); // Fallback brownish
} }
bool isSmoke = (uScrollSpeed > 0.0); // Alpha test for leaves, fences, etc.
if (uAlphaTest && texColor.a < 0.5) {
// Alpha test for leaves, fences, etc. (skip for smoke)
if (uAlphaTest && !isSmoke && texColor.a < 0.5) {
discard; discard;
} }
// Distance fade - discard nearly invisible fragments // Distance fade - discard nearly invisible fragments
float finalAlpha = texColor.a * uFadeAlpha; 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) { if (finalAlpha < 0.02) {
discard; discard;
} }
@ -300,10 +289,6 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
vec3 diffuse = diff * texColor.rgb; vec3 diffuse = diff * texColor.rgb;
vec3 result = ambient + diffuse; 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); FragColor = vec4(result, finalAlpha);
} }
)"; )";
@ -314,6 +299,91 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
return false; 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<Shader>();
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 // Create white fallback texture
uint8_t white[] = {255, 255, 255, 255}; uint8_t white[] = {255, 255, 255, 255};
glGenTextures(1, &whiteTexture); glGenTextures(1, &whiteTexture);
@ -354,6 +424,12 @@ void M2Renderer::shutdown() {
} }
shader.reset(); 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) { 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) { void M2Renderer::update(float deltaTime) {
float dtMs = deltaTime * 1000.0f; float dtMs = deltaTime * 1000.0f;
// --- Smoke particle spawning ---
std::uniform_real_distribution<float> distXY(-0.4f, 0.4f);
std::uniform_real_distribution<float> distVelXY(-0.3f, 0.3f);
std::uniform_real_distribution<float> distVelZ(3.0f, 5.0f);
std::uniform_real_distribution<float> distLife(4.0f, 7.0f);
std::uniform_real_distribution<float> 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<int>(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<float>(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) { for (auto& instance : instances) {
auto it = models.find(instance.modelId); auto it = models.find(instance.modelId);
if (it == models.end()) continue; 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; const M2ModelGPU& model = it->second;
if (!model.isValid()) continue; if (!model.isValid()) continue;
// Skip smoke models — replaced by particle emitters
if (model.isSmoke) continue;
// Distance culling for small objects (scaled by object size) // Distance culling for small objects (scaled by object size)
glm::vec3 toCam = instance.position - camPos; glm::vec3 toCam = instance.position - camPos;
float distSq = glm::dot(toCam, toCam); 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("uModel", instance.modelMatrix);
shader->setUniform("uFadeAlpha", fadeAlpha); 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 // Upload bone matrices if model has skeletal animation
bool useBones = model.hasAnimation && !instance.boneMatrices.empty(); bool useBones = model.hasAnimation && !instance.boneMatrices.empty();
shader->setUniform("uUseBones", useBones); 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); shader->setUniformMatrixArray("uBones[0]", instance.boneMatrices.data(), numBones);
} }
// Disable depth writes for fading objects and smoke to avoid z-fighting // Disable depth writes for fading objects to avoid z-fighting
if (fadeAlpha < 1.0f || isSmoke) { if (fadeAlpha < 1.0f) {
glDepthMask(GL_FALSE); glDepthMask(GL_FALSE);
} }
// Additive blending for smoke
if (isSmoke) {
glBlendFunc(GL_SRC_ALPHA, GL_ONE);
}
glBindVertexArray(model.vao); glBindVertexArray(model.vao);
for (const auto& batch : model.batches) { for (const auto& batch : model.batches) {
@ -1034,12 +1168,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
glBindVertexArray(0); glBindVertexArray(0);
// Restore blending mode after smoke if (fadeAlpha < 1.0f) {
if (isSmoke) {
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}
if (fadeAlpha < 1.0f || isSmoke) {
glDepthMask(GL_TRUE); glDepthMask(GL_TRUE);
} }
} }
@ -1049,6 +1178,56 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
glEnable(GL_CULL_FACE); 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<float> 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<float>(viewport[3]));
glBindVertexArray(smokeVAO);
glDrawArrays(GL_POINTS, 0, static_cast<GLsizei>(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) { void M2Renderer::removeInstance(uint32_t instanceId) {
for (auto it = instances.begin(); it != instances.end(); ++it) { for (auto it = instances.begin(); it != instances.end(); ++it) {
if (it->id == instanceId) { if (it->id == instanceId) {
@ -1069,6 +1248,8 @@ void M2Renderer::clear() {
instances.clear(); instances.clear();
spatialGrid.clear(); spatialGrid.clear();
instanceIndexById.clear(); instanceIndexById.clear();
smokeParticles.clear();
smokeEmitAccum = 0.0f;
} }
void M2Renderer::setCollisionFocus(const glm::vec3& worldPos, float radius) { void M2Renderer::setCollisionFocus(const glm::vec3& worldPos, float radius) {

View file

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