mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-07-31 09:49:53 -04:00
A lamp whose emitter yields a zero rate or lifespan glows but shows no flame, which is indistinguishable from a rendering problem. Log each such model once, at warning level so a default log captures it, with the track state that produced the zero.
753 lines
33 KiB
C++
753 lines
33 KiB
C++
#include "rendering/m2_renderer.hpp"
|
|
#include <unordered_set>
|
|
#include "rendering/m2_renderer_internal.h"
|
|
#include "rendering/vk_context.hpp"
|
|
#include "rendering/vk_buffer.hpp"
|
|
#include "rendering/vk_texture.hpp"
|
|
#include "rendering/vk_pipeline.hpp"
|
|
#include "rendering/vk_shader.hpp"
|
|
#include "rendering/vk_utils.hpp"
|
|
#include "rendering/camera.hpp"
|
|
#include "pipeline/asset_manager.hpp"
|
|
#include "core/logger.hpp"
|
|
#include <glm/gtc/matrix_transform.hpp>
|
|
#include <glm/gtc/type_ptr.hpp>
|
|
#include <glm/gtx/quaternion.hpp>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <random>
|
|
|
|
namespace wowee {
|
|
namespace rendering {
|
|
|
|
// --- M2 Particle Emitter Helpers ---
|
|
|
|
float M2Renderer::interpFloat(const pipeline::M2AnimationTrack& track, float animTime,
|
|
float globalTime, int seqIdx,
|
|
const std::vector<uint32_t>& globalSeqDurations) {
|
|
return m2_track::sampleFloat(track, seqIdx, animTime, globalTime,
|
|
globalSeqDurations, 0.0f);
|
|
}
|
|
|
|
// Interpolate an M2 FBlock (particle lifetime curve) at a given life ratio [0..1].
|
|
// FBlocks store per-lifetime keyframes for particle color, alpha, and scale.
|
|
// NOTE: interpFBlockFloat and interpFBlockVec3 share identical interpolation logic —
|
|
// if you fix a bug in one, update the other to match.
|
|
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);
|
|
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();
|
|
}
|
|
|
|
std::vector<glm::vec3> M2Renderer::getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const {
|
|
std::vector<glm::vec3> result;
|
|
float maxDistSq = maxDist * maxDist;
|
|
for (const auto& inst : instances) {
|
|
if (!inst.cachedModel || !inst.cachedModel->isWaterVegetation) continue;
|
|
glm::vec3 diff = inst.position - camPos;
|
|
if (glm::dot(diff, diff) <= maxDistSq) {
|
|
result.push_back(inst.position);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt) {
|
|
if (gpu.isInstancePortal) return;
|
|
|
|
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);
|
|
std::uniform_int_distribution<int> distTile;
|
|
|
|
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.globalSequenceTime,
|
|
inst.currentSequenceIndex, gpu.globalSequenceDurations);
|
|
float life = interpFloat(em.lifespan, inst.animTime, inst.globalSequenceTime,
|
|
inst.currentSequenceIndex, gpu.globalSequenceDurations);
|
|
if (rate <= 0.0f || life <= 0.0f) {
|
|
// Diagnostic: a lamp or flame whose emitter never fires produces a
|
|
// fixture that glows but shows no flame. Report each model once so a
|
|
// default-level log says whether emission is the cause.
|
|
if (gpu.isLanternLike || gpu.isTorch || gpu.isBrazierOrFire) {
|
|
static std::unordered_set<std::string> reported;
|
|
if (reported.insert(gpu.name).second) {
|
|
LOG_WARNING("Flame emitter idle: '", gpu.name, "' emitter=", ei,
|
|
" rate=", rate, " life=", life,
|
|
" animTime=", inst.animTime,
|
|
" seqIdx=", inst.currentSequenceIndex,
|
|
" gsTime=", inst.globalSequenceTime,
|
|
" rateSeqs=", em.emissionRate.sequences.size(),
|
|
" lifeSeqs=", em.lifespan.sequences.size(),
|
|
" rateGlobalSeq=", em.emissionRate.globalSequence);
|
|
}
|
|
}
|
|
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;
|
|
p.tileIndex = 0.0f;
|
|
|
|
// 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.globalSequenceTime,
|
|
inst.currentSequenceIndex, gpu.globalSequenceDurations);
|
|
float vRange = interpFloat(em.verticalRange, inst.animTime, inst.globalSequenceTime,
|
|
inst.currentSequenceIndex, gpu.globalSequenceDurations);
|
|
float hRange = interpFloat(em.horizontalRange, inst.animTime, inst.globalSequenceTime,
|
|
inst.currentSequenceIndex, 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 lenSq = glm::dot(dir, dir);
|
|
if (lenSq > 0.001f * 0.001f) dir *= glm::inversesqrt(lenSq);
|
|
|
|
// Transform direction by bone + model orientation (rotation only)
|
|
glm::mat3 rotMat = glm::mat3(inst.modelMatrix * boneXform);
|
|
p.velocity = rotMat * dir * speed;
|
|
|
|
// When emission speed is ~0 and bone animation isn't loaded (.anim files),
|
|
// particles pile up at the same position. Give them a drift so they
|
|
// spread outward like a mist/spray effect instead of clustering.
|
|
if (std::abs(speed) < 0.01f) {
|
|
if (gpu.isFireflyEffect) {
|
|
// Fireflies: gentle random drift in all directions
|
|
p.velocity = rotMat * glm::vec3(
|
|
distN(particleRng_) * 0.6f,
|
|
distN(particleRng_) * 0.6f,
|
|
distN(particleRng_) * 0.3f
|
|
);
|
|
} else {
|
|
p.velocity = rotMat * glm::vec3(
|
|
distN(particleRng_) * 1.0f,
|
|
distN(particleRng_) * 1.0f,
|
|
-dist01(particleRng_) * 0.5f
|
|
);
|
|
}
|
|
}
|
|
|
|
const uint32_t tilesX = std::max<uint16_t>(em.textureCols, 1);
|
|
const uint32_t tilesY = std::max<uint16_t>(em.textureRows, 1);
|
|
const uint32_t totalTiles = tilesX * tilesY;
|
|
if ((em.flags & kParticleFlagTiled) && totalTiles > 1) {
|
|
if (em.flags & kParticleFlagRandomized) {
|
|
distTile = std::uniform_int_distribution<int>(0, static_cast<int>(totalTiles - 1));
|
|
p.tileIndex = static_cast<float>(distTile(particleRng_));
|
|
} else {
|
|
p.tileIndex = 0.0f;
|
|
}
|
|
}
|
|
|
|
inst.particles.push_back(p);
|
|
|
|
// Diagnostic: log first particle birth per spell effect instance
|
|
if (gpu.isSpellEffect && inst.particles.size() == 1) {
|
|
LOG_INFO("SpellEffect: first particle for '", gpu.name,
|
|
"' pos=(", p.position.x, ",", p.position.y, ",", p.position.z,
|
|
") rate=", rate, " life=", life,
|
|
" bone=", em.bone, " boneCount=", inst.boneMatrices.size(),
|
|
" globalSeqs=", gpu.globalSequenceDurations.size());
|
|
}
|
|
}
|
|
// 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) {
|
|
if (!inst.cachedModel) return;
|
|
const auto& gpu = *inst.cachedModel;
|
|
|
|
// Hoist per-emitter gravity out of the per-particle loop. Gravity (and the
|
|
// emissionSpeed fallback) depends only on the emitter and animation time —
|
|
// not on the particle itself — so interpFloat was being re-evaluated for
|
|
// every particle even when 100s of particles share one emitter.
|
|
constexpr size_t kMaxStackEmitters = 16;
|
|
float emitterGravStack[kMaxStackEmitters];
|
|
std::vector<float> emitterGravHeap;
|
|
const size_t numEm = gpu.particleEmitters.size();
|
|
float* emitterGrav = nullptr;
|
|
if (numEm > 0) {
|
|
if (numEm <= kMaxStackEmitters) {
|
|
emitterGrav = emitterGravStack;
|
|
} else {
|
|
emitterGravHeap.resize(numEm);
|
|
emitterGrav = emitterGravHeap.data();
|
|
}
|
|
for (size_t e = 0; e < numEm; ++e) {
|
|
const auto& pem = gpu.particleEmitters[e];
|
|
float grav = interpFloat(pem.gravity,
|
|
inst.animTime, inst.globalSequenceTime,
|
|
inst.currentSequenceIndex, gpu.globalSequenceDurations);
|
|
if (grav == 0.0f && !gpu.isFireflyEffect) {
|
|
float emSpeed = interpFloat(pem.emissionSpeed,
|
|
inst.animTime, inst.globalSequenceTime,
|
|
inst.currentSequenceIndex, gpu.globalSequenceDurations);
|
|
grav = (std::abs(emSpeed) > 0.1f) ? 4.0f : 1.5f;
|
|
}
|
|
emitterGrav[e] = grav;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (p.emitterIndex >= 0 && static_cast<size_t>(p.emitterIndex) < numEm) {
|
|
p.velocity.z -= emitterGrav[p.emitterIndex] * dt;
|
|
}
|
|
p.position += p.velocity * dt;
|
|
i++;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ribbon emitter simulation
|
|
// ---------------------------------------------------------------------------
|
|
void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt) {
|
|
if (gpu.isInstancePortal) return;
|
|
|
|
const auto& emitters = gpu.ribbonEmitters;
|
|
if (emitters.empty()) return;
|
|
|
|
// Grow per-instance state arrays if needed
|
|
if (inst.ribbonEdges.size() != emitters.size()) {
|
|
inst.ribbonEdges.resize(emitters.size());
|
|
}
|
|
if (inst.ribbonEdgeAccumulators.size() != emitters.size()) {
|
|
inst.ribbonEdgeAccumulators.resize(emitters.size(), 0.0f);
|
|
}
|
|
|
|
for (size_t ri = 0; ri < emitters.size(); ri++) {
|
|
const auto& em = emitters[ri];
|
|
auto& edges = inst.ribbonEdges[ri];
|
|
auto& accum = inst.ribbonEdgeAccumulators[ri];
|
|
|
|
// Determine bone world position for spine
|
|
glm::vec3 spineWorld = inst.position;
|
|
// Use referenced bone; fall back to bone 0 if out of range (common for spell effects
|
|
// where ribbon bone fields may be unset/garbage, e.g. bone=4294967295)
|
|
uint32_t boneIdx = em.bone;
|
|
if (boneIdx >= inst.boneMatrices.size() && !inst.boneMatrices.empty()) {
|
|
boneIdx = 0;
|
|
}
|
|
if (boneIdx < inst.boneMatrices.size()) {
|
|
glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f);
|
|
spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[boneIdx] * local);
|
|
} else {
|
|
glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f);
|
|
spineWorld = glm::vec3(inst.modelMatrix * local);
|
|
}
|
|
|
|
// Skip emitters that produce NaN positions (garbage bone/position data)
|
|
if (std::isnan(spineWorld.x) || std::isnan(spineWorld.y) || std::isnan(spineWorld.z))
|
|
continue;
|
|
|
|
// Evaluate animated tracks (use first available sequence key, or fallback value)
|
|
auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float {
|
|
for (const auto& seq : track.sequences) {
|
|
if (!seq.floatValues.empty()) return seq.floatValues[0];
|
|
}
|
|
return fallback;
|
|
};
|
|
auto getVec3Val = [&](const pipeline::M2AnimationTrack& track, glm::vec3 fallback) -> glm::vec3 {
|
|
for (const auto& seq : track.sequences) {
|
|
if (!seq.vec3Values.empty()) return seq.vec3Values[0];
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
float visibility = getFloatVal(em.visibilityTrack, 1.0f);
|
|
float heightAbove = getFloatVal(em.heightAboveTrack, 0.5f);
|
|
float heightBelow = getFloatVal(em.heightBelowTrack, 0.5f);
|
|
glm::vec3 color = getVec3Val(em.colorTrack, glm::vec3(1.0f));
|
|
float alpha = getFloatVal(em.alphaTrack, 1.0f);
|
|
|
|
// Age existing edges and remove expired ones
|
|
for (auto& e : edges) {
|
|
e.age += dt;
|
|
// Apply gravity
|
|
if (em.gravity != 0.0f) {
|
|
e.worldPos.z -= em.gravity * dt * dt * 0.5f;
|
|
}
|
|
}
|
|
while (!edges.empty() && edges.front().age >= em.edgeLifetime) {
|
|
edges.pop_front();
|
|
}
|
|
|
|
// Emit new edges based on edgesPerSecond
|
|
if (visibility > 0.5f) {
|
|
accum += em.edgesPerSecond * dt;
|
|
while (accum >= 1.0f) {
|
|
accum -= 1.0f;
|
|
M2Instance::RibbonEdge e;
|
|
e.worldPos = spineWorld;
|
|
e.color = color;
|
|
e.alpha = alpha;
|
|
e.heightAbove = heightAbove;
|
|
e.heightBelow = heightBelow;
|
|
e.age = 0.0f;
|
|
edges.push_back(e);
|
|
|
|
// Diagnostic: log first ribbon edge per spell effect instance+emitter
|
|
if (gpu.isSpellEffect && edges.size() == 1) {
|
|
LOG_INFO("SpellEffect: ribbon edge[0] for '", gpu.name,
|
|
"' emitter=", ri, " pos=(", spineWorld.x, ",", spineWorld.y,
|
|
",", spineWorld.z, ") hA=", heightAbove, " hB=", heightBelow,
|
|
" vis=", visibility, " eps=", em.edgesPerSecond,
|
|
" edgeLife=", em.edgeLifetime, " bone=", em.bone);
|
|
}
|
|
|
|
// Cap trail length
|
|
if (edges.size() > 128) edges.pop_front();
|
|
}
|
|
} else {
|
|
accum = 0.0f;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ribbon rendering
|
|
// ---------------------------------------------------------------------------
|
|
void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) {
|
|
if (!ribbonPipeline_ || !ribbonAdditivePipeline_ || !ribbonVB_ || !ribbonVBMapped_) return;
|
|
|
|
// Build camera right vector for billboard orientation
|
|
// For ribbons we orient the quad strip along the spine with screen-space up.
|
|
// Simple approach: use world-space Z=up for the ribbon cross direction.
|
|
const glm::vec3 upWorld(0.0f, 0.0f, 1.0f);
|
|
|
|
float* dst = static_cast<float*>(ribbonVBMapped_);
|
|
size_t written = 0;
|
|
|
|
ribbonDraws_.clear();
|
|
auto& draws = ribbonDraws_;
|
|
|
|
for (const auto& inst : instances) {
|
|
if (!inst.cachedModel) continue;
|
|
const auto& gpu = *inst.cachedModel;
|
|
if (gpu.isInstancePortal) continue;
|
|
if (gpu.ribbonEmitters.empty()) continue;
|
|
|
|
for (size_t ri = 0; ri < gpu.ribbonEmitters.size(); ri++) {
|
|
if (ri >= inst.ribbonEdges.size()) continue;
|
|
const auto& edges = inst.ribbonEdges[ri];
|
|
if (edges.size() < 2) continue;
|
|
|
|
const auto& em = gpu.ribbonEmitters[ri];
|
|
|
|
// Select blend pipeline based on material blend mode
|
|
bool additive = false;
|
|
if (em.materialIndex < gpu.batches.size()) {
|
|
additive = (gpu.batches[em.materialIndex].blendMode >= 3);
|
|
}
|
|
VkPipeline pipe = additive ? ribbonAdditivePipeline_ : ribbonPipeline_;
|
|
|
|
// Descriptor set for texture
|
|
VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size())
|
|
? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE;
|
|
if (!texSet) {
|
|
if (gpu.isSpellEffect) {
|
|
static bool ribbonTexWarn = false;
|
|
if (!ribbonTexWarn) {
|
|
LOG_WARNING("SpellEffect: ribbon[", ri, "] for '", gpu.name,
|
|
"' has null texSet — descriptor pool may be exhausted");
|
|
ribbonTexWarn = true;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
uint32_t firstVert = static_cast<uint32_t>(written);
|
|
|
|
// Emit triangle strip: 2 verts per edge (top + bottom)
|
|
for (size_t ei = 0; ei < edges.size(); ei++) {
|
|
if (written + 2 > MAX_RIBBON_VERTS) break;
|
|
const auto& e = edges[ei];
|
|
float t = (em.edgeLifetime > 0.0f)
|
|
? 1.0f - (e.age / em.edgeLifetime) : 1.0f;
|
|
float a = e.alpha * t;
|
|
float u = static_cast<float>(ei) / static_cast<float>(edges.size() - 1);
|
|
|
|
// Top vertex (above spine along upWorld)
|
|
glm::vec3 top = e.worldPos + upWorld * e.heightAbove;
|
|
dst[written * 9 + 0] = top.x;
|
|
dst[written * 9 + 1] = top.y;
|
|
dst[written * 9 + 2] = top.z;
|
|
dst[written * 9 + 3] = e.color.r;
|
|
dst[written * 9 + 4] = e.color.g;
|
|
dst[written * 9 + 5] = e.color.b;
|
|
dst[written * 9 + 6] = a;
|
|
dst[written * 9 + 7] = u;
|
|
dst[written * 9 + 8] = 0.0f; // v = top
|
|
written++;
|
|
|
|
// Bottom vertex (below spine)
|
|
glm::vec3 bot = e.worldPos - upWorld * e.heightBelow;
|
|
dst[written * 9 + 0] = bot.x;
|
|
dst[written * 9 + 1] = bot.y;
|
|
dst[written * 9 + 2] = bot.z;
|
|
dst[written * 9 + 3] = e.color.r;
|
|
dst[written * 9 + 4] = e.color.g;
|
|
dst[written * 9 + 5] = e.color.b;
|
|
dst[written * 9 + 6] = a;
|
|
dst[written * 9 + 7] = u;
|
|
dst[written * 9 + 8] = 1.0f; // v = bottom
|
|
written++;
|
|
}
|
|
|
|
uint32_t vertCount = static_cast<uint32_t>(written) - firstVert;
|
|
if (vertCount >= 4) {
|
|
draws.push_back({texSet, pipe, firstVert, vertCount});
|
|
} else {
|
|
// Rollback if too few verts
|
|
written = firstVert;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Periodic diagnostic: spell ribbon draw count
|
|
{
|
|
static uint32_t ribbonDiagFrame_ = 0;
|
|
if (++ribbonDiagFrame_ % 300 == 1) {
|
|
size_t spellRibbonDraws = 0;
|
|
size_t spellRibbonVerts = 0;
|
|
for (const auto& inst : instances) {
|
|
if (!inst.cachedModel || !inst.cachedModel->isSpellEffect) continue;
|
|
for (size_t ri = 0; ri < inst.ribbonEdges.size(); ri++) {
|
|
if (inst.ribbonEdges[ri].size() >= 2) {
|
|
spellRibbonDraws++;
|
|
spellRibbonVerts += inst.ribbonEdges[ri].size() * 2;
|
|
}
|
|
}
|
|
}
|
|
if (spellRibbonDraws > 0 || !draws.empty()) {
|
|
LOG_INFO("SpellEffect: ", spellRibbonDraws, " spell ribbon strips (",
|
|
spellRibbonVerts, " verts), total draws=", draws.size(),
|
|
" written=", written);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (draws.empty() || written == 0) return;
|
|
|
|
VkExtent2D ext = vkCtx_->getSwapchainExtent();
|
|
VkViewport vp{};
|
|
vp.x = 0; vp.y = 0;
|
|
vp.width = static_cast<float>(ext.width);
|
|
vp.height = static_cast<float>(ext.height);
|
|
vp.minDepth = 0.0f; vp.maxDepth = 1.0f;
|
|
VkRect2D sc{};
|
|
sc.offset = {0, 0};
|
|
sc.extent = ext;
|
|
vkCmdSetViewport(cmd, 0, 1, &vp);
|
|
vkCmdSetScissor(cmd, 0, 1, &sc);
|
|
|
|
VkPipeline lastPipe = VK_NULL_HANDLE;
|
|
for (const auto& dc : draws) {
|
|
if (dc.pipeline != lastPipe) {
|
|
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dc.pipeline);
|
|
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
|
ribbonPipelineLayout_, 0, 1, &perFrameSet, 0, nullptr);
|
|
lastPipe = dc.pipeline;
|
|
}
|
|
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
|
ribbonPipelineLayout_, 1, 1, &dc.texSet, 0, nullptr);
|
|
VkDeviceSize offset = 0;
|
|
vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonVB_, &offset);
|
|
vkCmdDraw(cmd, dc.vertexCount, 1, dc.firstVertex, 0);
|
|
}
|
|
}
|
|
|
|
void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) {
|
|
if (!particlePipeline_ || !m2ParticleVB_) return;
|
|
|
|
// Collect all particles from all instances, grouped by texture+blend
|
|
// Reuse persistent map — clear each group's vertex data but keep bucket structure.
|
|
for (auto& [k, g] : particleGroups_) {
|
|
g.vertexData.clear();
|
|
g.preAllocSet = VK_NULL_HANDLE;
|
|
}
|
|
auto& groups = particleGroups_;
|
|
|
|
size_t totalParticles = 0;
|
|
|
|
for (auto& inst : instances) {
|
|
if (inst.particles.empty()) continue;
|
|
if (!inst.cachedModel) continue;
|
|
const auto& gpu = *inst.cachedModel;
|
|
if (gpu.isInstancePortal) continue;
|
|
|
|
// Cache the last emitter's per-emitter state so adjacent particles
|
|
// sharing an emitter (the common case — particles from one source
|
|
// cluster together) skip the texture/key/map-lookup work entirely.
|
|
int lastEmitterIdx = -1;
|
|
VkTexture* cachedTex = nullptr;
|
|
uint16_t cachedTilesX = 1, cachedTilesY = 1;
|
|
uint32_t cachedTotalTiles = 1;
|
|
uint16_t cachedBlendType = 0;
|
|
const pipeline::M2ParticleEmitter* cachedEm = nullptr;
|
|
ParticleGroup* cachedGroup = nullptr;
|
|
// animFrame depends only on inst.animTime + totalTiles, so it's also
|
|
// emitter-stable within one frame.
|
|
uint32_t cachedAnimFrame = 0;
|
|
float cachedTilesFloat = 1.0f;
|
|
bool cachedIsTiled = false;
|
|
float invAnimMs = 1.0f / 1000.0f;
|
|
|
|
for (const auto& p : inst.particles) {
|
|
if (p.emitterIndex < 0 || p.emitterIndex >= static_cast<int>(gpu.particleEmitters.size())) continue;
|
|
|
|
if (p.emitterIndex != lastEmitterIdx) {
|
|
lastEmitterIdx = p.emitterIndex;
|
|
cachedEm = &gpu.particleEmitters[p.emitterIndex];
|
|
|
|
cachedTex = whiteTexture_.get();
|
|
if (p.emitterIndex < static_cast<int>(gpu.particleTextures.size())) {
|
|
cachedTex = gpu.particleTextures[p.emitterIndex];
|
|
}
|
|
cachedTilesX = std::max<uint16_t>(cachedEm->textureCols, 1);
|
|
cachedTilesY = std::max<uint16_t>(cachedEm->textureRows, 1);
|
|
cachedTotalTiles = static_cast<uint32_t>(cachedTilesX) *
|
|
static_cast<uint32_t>(cachedTilesY);
|
|
cachedBlendType = cachedEm->blendingType;
|
|
ParticleGroupKey key{cachedTex, static_cast<uint8_t>(cachedBlendType), cachedTilesX, cachedTilesY};
|
|
cachedGroup = &groups[key];
|
|
cachedGroup->texture = cachedTex;
|
|
cachedGroup->blendType = cachedBlendType;
|
|
cachedGroup->tilesX = cachedTilesX;
|
|
cachedGroup->tilesY = cachedTilesY;
|
|
if (cachedGroup->preAllocSet == VK_NULL_HANDLE &&
|
|
p.emitterIndex < static_cast<int>(gpu.particleTexSets.size())) {
|
|
cachedGroup->preAllocSet = gpu.particleTexSets[p.emitterIndex];
|
|
}
|
|
|
|
cachedIsTiled = (cachedEm->flags & kParticleFlagTiled) && cachedTotalTiles > 1;
|
|
if (cachedIsTiled) {
|
|
float animSeconds = inst.animTime * invAnimMs;
|
|
cachedAnimFrame = static_cast<uint32_t>(std::floor(animSeconds * cachedTotalTiles))
|
|
% cachedTotalTiles;
|
|
cachedTilesFloat = static_cast<float>(cachedTotalTiles);
|
|
}
|
|
}
|
|
|
|
const auto& em = *cachedEm;
|
|
float lifeRatio = p.life / std::max(p.maxLife, 0.001f);
|
|
glm::vec3 color = interpFBlockVec3(em.particleColor, lifeRatio);
|
|
float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f);
|
|
float rawScale = interpFBlockFloat(em.particleScale, lifeRatio);
|
|
|
|
if (!gpu.isSpellEffect && !gpu.isFireflyEffect && !gpu.isLanternLike &&
|
|
!gpu.isTorch && !gpu.isBrazierOrFire && !gpu.isKoboldFlame) {
|
|
color = glm::mix(color, glm::vec3(1.0f), 0.7f);
|
|
if (rawScale > 2.0f) alpha *= 0.02f;
|
|
if (cachedBlendType == 3 || cachedBlendType == 4) alpha *= 0.05f;
|
|
}
|
|
float scale = rawScale;
|
|
if (gpu.isSpellEffect) {
|
|
scale = std::max(rawScale * 1.5f, 0.15f);
|
|
} else if (!gpu.isFireflyEffect) {
|
|
scale = std::min(rawScale, 1.5f);
|
|
}
|
|
|
|
auto& vd = cachedGroup->vertexData;
|
|
vd.push_back(p.position.x);
|
|
vd.push_back(p.position.y);
|
|
vd.push_back(p.position.z);
|
|
vd.push_back(color.r);
|
|
vd.push_back(color.g);
|
|
vd.push_back(color.b);
|
|
vd.push_back(alpha);
|
|
vd.push_back(scale);
|
|
float tileIndex = p.tileIndex;
|
|
if (cachedIsTiled) {
|
|
tileIndex = p.tileIndex + static_cast<float>(cachedAnimFrame);
|
|
while (tileIndex >= cachedTilesFloat) {
|
|
tileIndex -= cachedTilesFloat;
|
|
}
|
|
}
|
|
vd.push_back(tileIndex);
|
|
totalParticles++;
|
|
}
|
|
}
|
|
|
|
// Periodic diagnostic: spell effect particle count
|
|
{
|
|
static uint32_t spellParticleDiagFrame_ = 0;
|
|
if (++spellParticleDiagFrame_ % 300 == 1) {
|
|
size_t spellPtc = 0;
|
|
for (const auto& inst : instances) {
|
|
if (inst.cachedModel && inst.cachedModel->isSpellEffect)
|
|
spellPtc += inst.particles.size();
|
|
}
|
|
if (spellPtc > 0) {
|
|
LOG_INFO("SpellEffect: rendering ", spellPtc, " spell particles (",
|
|
totalParticles, " total)");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalParticles == 0) return;
|
|
|
|
// Bind per-frame set (set 0) for particle pipeline
|
|
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
|
particlePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr);
|
|
|
|
VkDeviceSize vbOffset = 0;
|
|
vkCmdBindVertexBuffers(cmd, 0, 1, &m2ParticleVB_, &vbOffset);
|
|
|
|
VkPipeline currentPipeline = VK_NULL_HANDLE;
|
|
|
|
for (auto& [key, group] : groups) {
|
|
if (group.vertexData.empty()) continue;
|
|
|
|
uint8_t blendType = group.blendType;
|
|
VkPipeline desiredPipeline = (blendType == 3 || blendType == 4)
|
|
? particleAdditivePipeline_ : particlePipeline_;
|
|
if (desiredPipeline != currentPipeline) {
|
|
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline);
|
|
currentPipeline = desiredPipeline;
|
|
}
|
|
|
|
// Use pre-allocated stable descriptor set; fall back to per-frame alloc only if unavailable
|
|
VkDescriptorSet texSet = group.preAllocSet;
|
|
if (texSet == VK_NULL_HANDLE) {
|
|
// Fallback: allocate per-frame (pool exhaustion risk — should not happen in practice)
|
|
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
|
|
ai.descriptorPool = materialDescPool_;
|
|
ai.descriptorSetCount = 1;
|
|
ai.pSetLayouts = &particleTexLayout_;
|
|
if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) {
|
|
VkTexture* tex = group.texture ? group.texture : whiteTexture_.get();
|
|
VkDescriptorImageInfo imgInfo = tex->descriptorInfo();
|
|
VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
|
write.dstSet = texSet;
|
|
write.dstBinding = 0;
|
|
write.descriptorCount = 1;
|
|
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
|
|
write.pImageInfo = &imgInfo;
|
|
vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr);
|
|
}
|
|
}
|
|
if (texSet != VK_NULL_HANDLE) {
|
|
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
|
particlePipelineLayout_, 1, 1, &texSet, 0, nullptr);
|
|
}
|
|
|
|
// Push constants: tileCount + alphaKey
|
|
struct { float tileX, tileY; int alphaKey; } pc = {
|
|
static_cast<float>(group.tilesX), static_cast<float>(group.tilesY),
|
|
(blendType == 1) ? 1 : 0
|
|
};
|
|
vkCmdPushConstants(cmd, particlePipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0,
|
|
sizeof(pc), &pc);
|
|
|
|
// Upload and draw in chunks
|
|
size_t count = group.vertexData.size() / 9;
|
|
size_t offset = 0;
|
|
while (offset < count) {
|
|
size_t batch = std::min(count - offset, MAX_M2_PARTICLES);
|
|
memcpy(m2ParticleVBMapped_, &group.vertexData[offset * 9], batch * 9 * sizeof(float));
|
|
vkCmdDraw(cmd, static_cast<uint32_t>(batch), 1, 0, 0);
|
|
offset += batch;
|
|
}
|
|
}
|
|
}
|
|
|
|
void M2Renderer::renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) {
|
|
if (smokeParticles.empty() || !smokePipeline_ || !smokeVB_) return;
|
|
|
|
// Build vertex data: pos(3) + lifeRatio(1) + size(1) + isSpark(1) per particle
|
|
size_t count = std::min(smokeParticles.size(), static_cast<size_t>(MAX_SMOKE_PARTICLES));
|
|
float* dst = static_cast<float*>(smokeVBMapped_);
|
|
for (size_t i = 0; i < count; i++) {
|
|
const auto& p = smokeParticles[i];
|
|
*dst++ = p.position.x;
|
|
*dst++ = p.position.y;
|
|
*dst++ = p.position.z;
|
|
*dst++ = p.life / p.maxLife;
|
|
*dst++ = p.size;
|
|
*dst++ = p.isSpark;
|
|
}
|
|
|
|
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, smokePipeline_);
|
|
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
|
smokePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr);
|
|
|
|
// Push constant: screenHeight
|
|
float screenHeight = static_cast<float>(vkCtx_->getSwapchainExtent().height);
|
|
vkCmdPushConstants(cmd, smokePipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0,
|
|
sizeof(float), &screenHeight);
|
|
|
|
VkDeviceSize offset = 0;
|
|
vkCmdBindVertexBuffers(cmd, 0, 1, &smokeVB_, &offset);
|
|
vkCmdDraw(cmd, static_cast<uint32_t>(count), 1, 0, 0);
|
|
}
|
|
|
|
} // namespace rendering
|
|
} // namespace wowee
|