diff --git a/include/threepp/objects/ParticleSystem.hpp b/include/threepp/objects/ParticleSystem.hpp index 252de215..550c2001 100644 --- a/include/threepp/objects/ParticleSystem.hpp +++ b/include/threepp/objects/ParticleSystem.hpp @@ -10,6 +10,14 @@ namespace threepp { + // Material-name marker identifying the ParticleSystem billboard mesh. GL/WGPU + // compile the custom particle ShaderMaterial directly, but the Vulkan renderer + // has no generic ShaderMaterial path — it recognizes the particle Mesh by + // material()->name == kParticleMaterialName and routes it to a dedicated + // world-space billboard overlay pass instead of the PT/G-buffer path (where + // the un-expanded quad would be a zero-area, invisible triangle). + inline constexpr const char* kParticleMaterialName = "__threepp_particle__"; + class ParticleSystem: public Object3D { public: diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 06d69e71..195fbd5d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1117,6 +1117,27 @@ if (THREEPP_WITH_VULKAN) "${CMAKE_CURRENT_SOURCE_DIR}/threepp/renderers/vulkan/shaders/overlay_sprite.frag" kOverlaySpriteFragSpv _overlay_sprite_frag_spv_h) + # World-space particle billboards (ParticleSystem). The Vulkan backend has no + # generic ShaderMaterial path, so the particle Mesh is drawn by a dedicated + # billboard pass in the post-TAA overlay block — depth-tested against the + # G-buffer depth, composited onto the swapchain. Two blend variants (alpha / + # additive) selected per material at draw time. + compile_vulkan_shader(threepp + "${CMAKE_CURRENT_SOURCE_DIR}/threepp/renderers/vulkan/shaders/particle.vert" + kParticleVertSpv + _particle_vert_spv_h) + compile_vulkan_shader(threepp + "${CMAKE_CURRENT_SOURCE_DIR}/threepp/renderers/vulkan/shaders/particle.frag" + kParticleFragSpv + _particle_frag_spv_h) + # World-space Sprite billboards (screenSpace == false) — perspective, + # depth-tested counterpart to the ortho overlay_sprite HUD path. Shares + # overlay_sprite.frag; only the vertex shader differs (perspective reverse-Z, + # no GL z-remap). Drawn in the post-TAA overlay block. + compile_vulkan_shader(threepp + "${CMAKE_CURRENT_SOURCE_DIR}/threepp/renderers/vulkan/shaders/sprite3d.vert" + kSprite3dVertSpv + _sprite3d_vert_spv_h) endif () diff --git a/src/threepp/objects/ParticleSystem.cpp b/src/threepp/objects/ParticleSystem.cpp index 0d3c6939..90262563 100644 --- a/src/threepp/objects/ParticleSystem.cpp +++ b/src/threepp/objects/ParticleSystem.cpp @@ -276,6 +276,9 @@ struct ParticleSystem::Impl { particleCount = settings.particlesPerSecond * std::min(settings.particleDeathAge, settings.emitterDeathAge); particleMaterial = ShaderMaterial::create(); + // Tag so backends without a generic ShaderMaterial path (Vulkan) can + // recognize this as a particle billboard mesh — see kParticleMaterialName. + particleMaterial->name = kParticleMaterialName; particleMaterial->vertexShader = particleVertexShader; particleMaterial->fragmentShader = particleFragmentShader; particleMaterial->transparent = true; diff --git a/src/threepp/renderers/VulkanRenderer.cpp b/src/threepp/renderers/VulkanRenderer.cpp index d9ffa4c0..8c771abb 100644 --- a/src/threepp/renderers/VulkanRenderer.cpp +++ b/src/threepp/renderers/VulkanRenderer.cpp @@ -68,10 +68,12 @@ #include "threepp/objects/GrassMesh.hpp" #include "threepp/objects/InstancedMesh.hpp" #include "threepp/objects/Mesh.hpp" +#include "threepp/objects/ParticleSystem.hpp" #include "threepp/objects/Skeleton.hpp" #include "threepp/objects/SkinnedMesh.hpp" #include "threepp/objects/Sprite.hpp" #include "threepp/materials/SpriteMaterial.hpp" +#include "threepp/materials/ShaderMaterial.hpp" #include "threepp/renderers/VulkanRenderer.hpp" #include "threepp/renderers/vulkan/water/OceanFFT.hpp" #include "threepp/scenes/Scene.hpp" @@ -107,6 +109,9 @@ #include "threepp/renderers/vulkan/shaders/event_shade.comp.spv.h" #include "threepp/renderers/vulkan/shaders/overlay_sprite.vert.spv.h" #include "threepp/renderers/vulkan/shaders/overlay_sprite.frag.spv.h" +#include "threepp/renderers/vulkan/shaders/particle.vert.spv.h" +#include "threepp/renderers/vulkan/shaders/particle.frag.spv.h" +#include "threepp/renderers/vulkan/shaders/sprite3d.vert.spv.h" #include "threepp/renderers/wgpu/pathtracer/WgpuPathTracerBCn.hpp" @@ -849,6 +854,12 @@ namespace threepp { // raster G-buffer, and emissive-tri NEE so PT can't see/shadow // them; drawn instead by the post-TAA overlay pass. bool isOverlay = false; + // ParticleSystem billboard mesh (material name == kParticleMaterialName). + // Implies isOverlay (so all the PT-exclusion guards apply), but is + // drawn by the dedicated billboard particle pass — NOT the wireframe/ + // basic overlay-mesh loop, which would render its un-expanded quads as + // zero-area triangles. The overlay-mesh loop skips on this flag. + bool isParticle = false; // Cached type probes. Resolved once per Mesh in ensureSceneBuilt's // traverseVisible callback (before the InstancedMesh fork so an // N-instance mesh costs 3 dynamic_casts, not 3·N). Consumers @@ -924,6 +935,7 @@ namespace threepp { static constexpr uint32_t kSnapWire = 16u; static constexpr uint32_t kSnapOverlay = 32u; static constexpr uint32_t kSnapTet = 64u; + static constexpr uint32_t kSnapParticle = 128u; std::vector sceneSnapshot_; // Classification-routing flags for a mesh — shared by the snapshot @@ -938,6 +950,10 @@ namespace threepp { if (overlayLayer_ >= 0 && m.layers.isEnabled(static_cast(overlayLayer_))) fl |= kSnapOverlay; if (auto mat = m.material(); mat && mat->tetSkinning && mat->tetTexture) fl |= kSnapTet; + // ParticleSystem billboard mesh — detected by the unique material-name + // marker (cheap: a length-mismatch reject for the empty-named common + // case). Routed to the dedicated billboard pass and excluded from PT. + if (auto mat = m.material(); mat && mat->name == kParticleMaterialName) fl |= kSnapParticle; return fl; } @@ -985,9 +1001,11 @@ namespace threepp { const bool over = overlayLayer_ >= 0 && o.layers.isEnabled(static_cast(overlayLayer_)); const bool tet = sn.mat && sn.mat->tetSkinning && sn.mat->tetTexture != nullptr; + const bool particle = sn.mat && sn.mat->name == kParticleMaterialName; if (wire != ((sn.flags & kSnapWire) != 0u) || over != ((sn.flags & kSnapOverlay) != 0u) || - tet != ((sn.flags & kSnapTet) != 0u)) { + tet != ((sn.flags & kSnapTet) != 0u) || + particle != ((sn.flags & kSnapParticle) != 0u)) { ok = false; return; } @@ -1380,6 +1398,82 @@ namespace threepp { // recordRasterGbufPass and only renders non-overlay geometry. VkPipeline overlayDepthPrepassPipeline = VK_NULL_HANDLE; + // ── ParticleSystem billboard pass ─────────────────────────────────── + // The Vulkan backend has no generic ShaderMaterial path, so the particle + // Mesh (a custom billboard ShaderMaterial whose quad is expanded in the + // vertex shader) is drawn here, in the post-TAA overlay block: depth- + // tested against unjitDepth, composited onto the swapchain. Two blend + // variants chosen per material at draw time. See createParticlePipeline / + // the particle draw loop in the overlay block, and particle.vert/.frag. + VkDescriptorSetLayout particleDescSetLayout_ = VK_NULL_HANDLE; + VkPipelineLayout particlePipelineLayout_ = VK_NULL_HANDLE; + VkPipeline particlePipelineNormal_ = VK_NULL_HANDLE;// alpha blend, depth-test on + VkPipeline particlePipelineAdditive_ = VK_NULL_HANDLE;// additive blend, depth-test off + // Per-frame combined-image-sampler pool, reset at the top of the draw + // loop (mirrors OverlayPass::spriteDescPools_). + std::array particleDescPools_{}; + // 1×1 white default bound when a particle system has no texture. + Image2D particleWhiteTex_{}; + static constexpr uint32_t kMaxParticleTexPerFrame = 64; + + // Per-BufferGeometry vertex/index buffers for particle billboards. The + // animated attributes (position/normal/color) are re-uploaded every frame + // (version-gated); uv + index are static (uploaded once). Particles own + // these buffers directly — they never build a BLAS. Single-buffered with + // in-place memcpy, exactly like ensureLineGeometryUploaded: a write-during- + // read race with the other in-flight frame is benign for an overlay visual + // (at most a sub-pixel tear on a fast-moving particle for one frame). + struct ParticleGeomRec { + Buffer position;// vec3 — particle centers (all 4 quad verts equal) + Buffer normal; // vec3 — {size, angle, opacity} + Buffer uv; // vec2 — corner offset + Buffer color; // vec3 — RGB + Buffer index; // uint32 + uint32_t indexCount = 0; + uint32_t vertexCount = 0; + unsigned int animVersion = ~0u;// pos+normal+color composite version + std::weak_ptr liveCheck; + }; + std::unordered_map particleGeomCache_; + + // Per-Texture sampled image for particle textures. Keyed on the raw + // Texture* (the ShaderMaterial uniform holds no shared_ptr) + version; + // re-upload is vkDeviceWaitIdle-guarded. Pointer-recycle with an + // identical version is a documented edge case. + struct ParticleTexRec { + Image2D image{}; + unsigned int version = ~0u; + uint32_t width = 0; + uint32_t height = 0; + }; + std::unordered_map particleTexCache_; + + // ── World-space Sprite billboards (screenSpace == false) ──────────── + // 3D-positioned camera-facing sprites (e.g. the TPS shooter's impact + // "particles"). The Vulkan renderer only composites screen-space (HUD) + // sprites via OverlayPass; world-space ones are drawn here in the + // depth-tested overlay block. Reuses particlePipelineLayout_ + + // particleDescSetLayout_ + particleDescPools_ + particleTexCache_ (the + // push-constant size, set-0 sampler layout, and texture cache all match); + // only the pipeline (perspective billboard vertex shader + interleaved + // pos/uv vertex input) and the shared static quad are new. + VkPipeline spriteWorldPipeline_ = VK_NULL_HANDLE; + // Shared canonical sprite quad (4 interleaved pos.xyz+uv.xy verts + 6 + // indices) — identical for every Sprite, so one static copy serves all. + Buffer spriteQuadVtx_{}; + Buffer spriteQuadIdx_{}; + // Per-frame snapshot of visible world-space sprites, rebuilt each + // perspective frame by collectWorldSprites() (sprites move/spawn/expire + // constantly, so this is a fresh walk, not snapshot-cached). + struct WorldSpriteEntry { + std::array world; + std::array color; // rgb + opacity + Vector2 center; + float rotation = 0.f; + const Texture* tex = nullptr; + }; + std::vector lastVisibleSprites_; + // Per-Line scene snapshot, refreshed in ensureSceneBuilt alongside // lastVisibleEntries_. Lives only for the overlay record's draw // loop — neither PT nor the raster G-buffer touches this. @@ -1402,6 +1496,13 @@ namespace threepp { // uploadRasterCameraUbo and read by recordOverlayPass to build // the per-draw mvp = vpUnjit · model push constant. std::array currVPunjit_{}; + // Cached unjittered view and reverse-Z projection matrices, mirrored + // alongside currVPunjit_ each frame. The particle billboard pass needs + // them SEPARATELY (not the combined VP): the distance-attenuated + // billboard scale uses view-space depth and proj[1][1] individually, so + // it pushes modelView = currViewUnjit_ · meshWorld and currProjUnjit_. + std::array currViewUnjit_{}; + std::array currProjUnjit_{}; // Per-frame raster camera data. currVPjittered drives gl_Position; // currVPunjittered + prevVP drive the motion-vector computation @@ -1632,7 +1733,13 @@ namespace threepp { // any command recording, so this is just two size/flag checks. bool sceneHasOverlayContent() const { if (!lastVisibleLines_.empty()) return true; + // World-space sprites are drawn in the overlay pass and depth-test + // against unjitDepth, so they need the prepass + overlay pass too. + if (!lastVisibleSprites_.empty()) return true; for (const auto& en : lastVisibleEntries_) { + // isOverlay covers particle billboards too (kSnapParticle folds + // into isOverlay), so a scene with only particles still triggers + // the depth prepass + overlay pass the billboard loop needs. if (en.isOverlay) return true; } return false; @@ -2063,6 +2170,38 @@ namespace threepp { if (overlayPointListPipeline) vkDestroyPipeline(d, overlayPointListPipeline, nullptr); if (overlayDepthPrepassPipeline) vkDestroyPipeline(d, overlayDepthPrepassPipeline, nullptr); if (overlayPipelineLayout) vkDestroyPipelineLayout(d, overlayPipelineLayout, nullptr); + // Particle billboard pass resources. + if (particlePipelineNormal_) vkDestroyPipeline(d, particlePipelineNormal_, nullptr); + if (particlePipelineAdditive_) vkDestroyPipeline(d, particlePipelineAdditive_, nullptr); + if (particlePipelineLayout_) vkDestroyPipelineLayout(d, particlePipelineLayout_, nullptr); + if (particleDescSetLayout_) vkDestroyDescriptorSetLayout(d, particleDescSetLayout_, nullptr); + for (auto& pool : particleDescPools_) { + if (pool) vkDestroyDescriptorPool(d, pool, nullptr); + } + if (spriteWorldPipeline_) vkDestroyPipeline(d, spriteWorldPipeline_, nullptr); + destroyBuffer(ctx->allocator(), spriteQuadVtx_); + destroyBuffer(ctx->allocator(), spriteQuadIdx_); + if (particleWhiteTex_.view != VK_NULL_HANDLE) + destroyImage2D(ctx->allocator(), d, particleWhiteTex_); + for (auto& [t, rec] : particleTexCache_) { + destroyImage2D(ctx->allocator(), d, rec.image); + } + particleTexCache_.clear(); + for (auto& [g, rec] : particleGeomCache_) { + destroyParticleGeomRec(rec); + } + particleGeomCache_.clear(); + // 3D hybrid-overlay line/point geometry cache (GridHelper, AxesHelper, + // live point clouds). Pre-existing: this Impl-level cache had no + // teardown, so its vertex/index/color buffers leaked at device + // destroy (VUID-vkDestroyDevice-device-05137) for any scene with a + // Line/Points overlay. Mirror OverlayPass's own lineGeomCache_ cleanup. + for (auto& [g, rec] : lineGeomCache_) { + destroyBuffer(ctx->allocator(), rec.vertex); + if (rec.index.handle != VK_NULL_HANDLE) destroyBuffer(ctx->allocator(), rec.index); + if (rec.color.handle != VK_NULL_HANDLE) destroyBuffer(ctx->allocator(), rec.color); + } + lineGeomCache_.clear(); overlayPass_.reset();// destroy sprite/line pipelines + caches while device is alive if (gbufSampler_) vkDestroySampler(d, gbufSampler_, nullptr); destroyBuffer(ctx->allocator(), dummyUvBuffer_); @@ -4967,7 +5106,13 @@ namespace threepp { auto geom = m->geometry(); if (!geom || !geom->hasAttribute("position")) return; if (!geom->hasAttribute("normal")) return; - const bool isOverlay = (sn.flags & (kSnapWire | kSnapOverlay)) != 0u; + // Particle billboard meshes are excluded from PT exactly like + // overlays (kSnapParticle folds into isOverlay so every + // `if (en.isOverlay) continue;` guard applies) but carry their + // own isParticle flag so the billboard pass claims them and the + // overlay-mesh loop skips them. + const bool isParticle = (sn.flags & kSnapParticle) != 0u; + const bool isOverlay = (sn.flags & (kSnapWire | kSnapOverlay | kSnapParticle)) != 0u; // One-shot type probes: an N-instance InstancedMesh costs 3 // dynamic_casts total, not 3·N — and on snapshot-match frames // none at all (the cached entry flags are reused). Consumed by @@ -4994,6 +5139,7 @@ namespace threepp { e.mesh = m; e.instanceIndex = static_cast(j); e.isOverlay = isOverlay; + e.isParticle = isParticle; e.isSkinned = isSkinned; e.isDisplaced = isDisplaced; e.isGrass = isGrass; @@ -5008,6 +5154,7 @@ namespace threepp { e.mesh = m; e.instanceIndex = 0u; e.isOverlay = isOverlay; + e.isParticle = isParticle; e.isSkinned = isSkinned; e.isDisplaced = isDisplaced; e.isGrass = isGrass; @@ -5105,10 +5252,17 @@ namespace threepp { const bool geomChanged = (gv != fp.geomVersion); if (geomChanged) { fp.geomVersion = gv; - geomDirtyAny = true; - entryGeomDirty[i] = true; - // boundingBox invalidation — mirrors the generic loop. - if (auto gg = en.mesh->geometry()) gg->boundingBox.reset(); + // Particle billboard meshes mutate their attributes every + // frame but own no BLAS — flagging geomDirty would fire a + // per-frame vkDeviceWaitIdle for a refit that skips them + // anyway (blasCache miss). The billboard pass re-uploads + // their vertex cache itself, version-gated. + if (!en.isParticle) { + geomDirtyAny = true; + entryGeomDirty[i] = true; + // boundingBox invalidation — mirrors the generic loop. + if (auto gg = en.mesh->geometry()) gg->boundingBox.reset(); + } } if (xfmChanged) { matricesSame = false; @@ -5345,7 +5499,11 @@ namespace threepp { if (bonesChanged) bonesDirtyAny = true; if (dispChanged) displacedDirtyAny = true; if (grassChanged) grassDirtyAny = true; - if (geomChanged) { + // Particle billboard meshes mutate attributes every frame but + // own no BLAS; never flag them geomDirty (would fire a per- + // frame vkDeviceWaitIdle for a refit that skips them). The + // billboard pass re-uploads their vertex cache itself. + if (geomChanged && !entries[i].isParticle) { geomDirtyAny = true; entryGeomDirty[i] = true; // Invalidate the cached boundingBox so the next @@ -5970,6 +6128,11 @@ namespace threepp { for (size_t i = 0; i < entries.size(); ++i) { const MeshEntry& en = entries[i]; Mesh* m = en.mesh; + // Particle billboard meshes own their vertex buffers in the + // dedicated billboard pass — they need no BLAS and never enter + // the TLAS. Skip before the geometry-keyed build so we don't + // allocate (or per-frame refit) an AS for them. + if (en.isParticle) continue; const BufferGeometry* geomKey = m->geometry().get(); // Skinned meshes get a per-instance deformed BLAS rather than @@ -7076,6 +7239,11 @@ namespace threepp { // the raster prepass + TAA used so wireframes register pixel- // exact with the post-TAA path-traced silhouette. std::memcpy(currVPunjit_.data(), vpUnj.elements.data(), 64); + // Mirror the unjittered view + reverse-Z projection separately for + // the particle billboard pass (it can't use the combined VP — see + // currViewUnjit_/currProjUnjit_). + std::memcpy(currViewUnjit_.data(), view.elements.data(), 64); + std::memcpy(currProjUnjit_.data(), proj.elements.data(), 64); // First frame: self-seed prevVP so motion vectors are zero. The // following frame picks up the real history. std::memcpy(ubo.prevVP, @@ -8114,6 +8282,188 @@ namespace threepp { return out; } + // ── ParticleSystem billboard resources ────────────────────────────── + + // Lazily build the 1×1 white texel bound for untextured particle + // systems (matches the GL path, where an unset `tex` sampler reads + // white). Created once; freed in deinit. + void ensureParticleWhiteTexture() { + if (particleWhiteTex_.view != VK_NULL_HANDLE) return; + const uint8_t white[4] = {255, 255, 255, 255}; + particleWhiteTex_ = createSampledImage2D( + 1, 1, VK_FORMAT_R8G8B8A8_UNORM, white, sizeof(white), + VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + "particleWhiteDefault"); + } + + // Upload/refresh a particle texture, keyed on the raw Texture* (the + // ShaderMaterial uniform holds no shared_ptr). Returns the cached + // Image2D, or nullptr (caller falls back to the white default). Mirrors + // OverlayPass::ensureSpriteAtlasTexture, minus the weak_ptr liveCheck. + const Image2D* ensureParticleTexture(const Texture* tex) { + if (!tex) return nullptr; + Image& img = const_cast(tex)->image(); + const uint32_t w = img.width(); + const uint32_t h = img.height(); + if (w == 0 || h == 0) return nullptr; + + const unsigned int curVersion = tex->version(); + auto it = particleTexCache_.find(tex); + if (it != particleTexCache_.end()) { + ParticleTexRec& rec = it->second; + const bool stale = rec.version != curVersion || + rec.width != w || rec.height != h; + if (!stale) return &rec.image; + vkDeviceWaitIdle(ctx->device()); + destroyImage2D(ctx->allocator(), ctx->device(), rec.image); + particleTexCache_.erase(it); + } + + std::vector rgba; + const size_t pixels = static_cast(w) * h; + try { + auto& src = img.data(); + if (src.size() == pixels * 4) { + rgba.assign(src.begin(), src.end()); + } else if (src.size() == pixels * 3) { + rgba.resize(pixels * 4); + for (size_t i = 0; i < pixels; ++i) { + rgba[i * 4 + 0] = src[i * 3 + 0]; + rgba[i * 4 + 1] = src[i * 3 + 1]; + rgba[i * 4 + 2] = src[i * 3 + 2]; + rgba[i * 4 + 3] = 255u; + } + } else { + return nullptr; + } + } catch (const std::bad_variant_access&) { + return nullptr; + } + + // Same colorSpace→format rule as the sprite/bindless paths: only an + // explicitly sRGB-tagged texture gets hardware sRGB decode on sample; + // particle.frag re-encodes the linear product for the UNORM swapchain. + const VkFormat fmt = (tex->colorSpace == ColorSpace::sRGB) + ? VK_FORMAT_R8G8B8A8_SRGB + : VK_FORMAT_R8G8B8A8_UNORM; + char name[64]; + std::snprintf(name, sizeof(name), "particleTex[%p]", + static_cast(tex)); + Image2D up = createSampledImage2D( + w, h, fmt, rgba.data(), rgba.size(), + VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + name); + ParticleTexRec rec{}; + rec.image = up; + rec.version = curVersion; + rec.width = w; + rec.height = h; + auto [ins, _] = particleTexCache_.emplace(tex, std::move(rec)); + return &ins->second.image; + } + + void destroyParticleGeomRec(ParticleGeomRec& rec) { + destroyBuffer(ctx->allocator(), rec.position); + destroyBuffer(ctx->allocator(), rec.normal); + destroyBuffer(ctx->allocator(), rec.uv); + destroyBuffer(ctx->allocator(), rec.color); + destroyBuffer(ctx->allocator(), rec.index); + } + + // Ensure the per-geometry particle vertex/index buffers exist and the + // animated attributes (position/normal/color) are current. uv + index + // are static (uploaded once). Returns nullptr on malformed geometry. + ParticleGeomRec* ensureParticleGeom(const std::shared_ptr& geomSp) { + BufferGeometry* geom = geomSp.get(); + if (!geom) return nullptr; + auto* posAttr = geom->getAttribute("position"); + auto* normAttr = geom->getAttribute("normal"); + auto* uvAttr = geom->getAttribute("uv"); + auto* colAttr = geom->getAttribute("color"); + auto* idxAttr = geom->getIndex(); + if (!posAttr || !normAttr || !uvAttr || !colAttr || !idxAttr) return nullptr; + + const uint32_t vtx = static_cast(posAttr->count()); + const uint32_t idxCount = static_cast(idxAttr->count()); + if (vtx == 0 || idxCount == 0) return nullptr; + // Particle attributes are vec3/vec3/vec2/vec3 — bail if a custom + // geometry doesn't match (the billboard pipeline assumes this layout). + if (normAttr->count() != static_cast(vtx) || + uvAttr->count() != static_cast(vtx) || + colAttr->count() != static_cast(vtx)) return nullptr; + + const unsigned int ver = geomVersionOf(*geom); + + auto uploadAnim = [&](ParticleGeomRec& rec) { + void* m = nullptr; + vmaMapMemory(ctx->allocator(), rec.position.alloc, &m); + std::memcpy(m, posAttr->array().data(), vtx * 3 * sizeof(float)); + vmaUnmapMemory(ctx->allocator(), rec.position.alloc); + vmaMapMemory(ctx->allocator(), rec.normal.alloc, &m); + std::memcpy(m, normAttr->array().data(), vtx * 3 * sizeof(float)); + vmaUnmapMemory(ctx->allocator(), rec.normal.alloc); + vmaMapMemory(ctx->allocator(), rec.color.alloc, &m); + std::memcpy(m, colAttr->array().data(), vtx * 3 * sizeof(float)); + vmaUnmapMemory(ctx->allocator(), rec.color.alloc); + }; + + auto it = particleGeomCache_.find(geom); + if (it != particleGeomCache_.end()) { + ParticleGeomRec& rec = it->second; + const bool stale = rec.vertexCount != vtx || rec.indexCount != idxCount || + rec.liveCheck.expired() || rec.liveCheck.lock().get() != geom; + if (!stale) { + if (rec.animVersion != ver) { + uploadAnim(rec); + rec.animVersion = ver; + } + return &rec; + } + // Topology change / recycled address — rebuild from scratch. + vkDeviceWaitIdle(ctx->device()); + destroyParticleGeomRec(rec); + particleGeomCache_.erase(it); + } + + // Fresh build. All buffers host-visible; pos/normal/color re-uploaded + // each frame, uv + index written once here. + const VkBufferUsageFlags vbUsage = + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT; + ParticleGeomRec rec{}; + rec.vertexCount = vtx; + rec.indexCount = idxCount; + rec.liveCheck = geomSp; + auto mkBuf = [&](VkDeviceSize bytes) { + return createBuffer(ctx->allocator(), ctx->device(), bytes, vbUsage, + VMA_MEMORY_USAGE_AUTO, + VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT); + }; + rec.position = mkBuf(vtx * 3 * sizeof(float)); + rec.normal = mkBuf(vtx * 3 * sizeof(float)); + rec.uv = mkBuf(vtx * 2 * sizeof(float)); + rec.color = mkBuf(vtx * 3 * sizeof(float)); + rec.index = createBuffer(ctx->allocator(), ctx->device(), + idxCount * sizeof(uint32_t), + VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VMA_MEMORY_USAGE_AUTO, + VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT); + void* m = nullptr; + vmaMapMemory(ctx->allocator(), rec.uv.alloc, &m); + std::memcpy(m, uvAttr->array().data(), vtx * 2 * sizeof(float)); + vmaUnmapMemory(ctx->allocator(), rec.uv.alloc); + vmaMapMemory(ctx->allocator(), rec.index.alloc, &m); + std::memcpy(m, idxAttr->array().data(), idxCount * sizeof(uint32_t)); + vmaUnmapMemory(ctx->allocator(), rec.index.alloc); + auto [ins, _] = particleGeomCache_.emplace(geom, std::move(rec)); + uploadAnim(ins->second); + ins->second.animVersion = ver; + return &ins->second; + } + // Storage-image (rgba32f, GENERAL layout) used as the // progressive accumulation target. No staging upload — contents are // initialised the first frame after sampleIndex resets to 0. The @@ -9490,6 +9840,382 @@ namespace threepp { } } + // ParticleSystem billboard pipelines. Two variants (alpha-blended / + // additive) that differ only in blend + depth-test state. Both: 4 vertex + // bindings (pos/normal/uv/color), a combined-image-sampler set 0, a 128B + // push constant (modelView + proj), dynamic viewport/scissor, dynamic + // rendering onto the swapchain + unjitDepth (read-only). Modeled on + // createOverlayPipeline + OverlayPass::createSpriteOverlayPipeline. + void createParticlePipeline() { + if (particlePipelineNormal_ != VK_NULL_HANDLE) return; + + // set 0, binding 0: combined image sampler (particle texture). + VkDescriptorSetLayoutBinding b{}; + b.binding = 0; + b.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + b.descriptorCount = 1; + b.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo dslci{}; + dslci.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + dslci.bindingCount = 1; + dslci.pBindings = &b; + check(vkCreateDescriptorSetLayout(ctx->device(), &dslci, nullptr, &particleDescSetLayout_), + "vkCreateDescriptorSetLayout(particle)"); + + VkPushConstantRange pcRange{}; + pcRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pcRange.offset = 0; + pcRange.size = 128;// mat4 modelView (64) + mat4 proj (64) + VkPipelineLayoutCreateInfo plci{}; + plci.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + plci.setLayoutCount = 1; + plci.pSetLayouts = &particleDescSetLayout_; + plci.pushConstantRangeCount = 1; + plci.pPushConstantRanges = &pcRange; + check(vkCreatePipelineLayout(ctx->device(), &plci, nullptr, &particlePipelineLayout_), + "vkCreatePipelineLayout(particle)"); + + for (uint32_t f = 0; f < kFramesInFlight; ++f) { + VkDescriptorPoolSize ps{}; + ps.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + ps.descriptorCount = kMaxParticleTexPerFrame; + VkDescriptorPoolCreateInfo dpci{}; + dpci.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + dpci.maxSets = kMaxParticleTexPerFrame; + dpci.poolSizeCount = 1; + dpci.pPoolSizes = &ps; + check(vkCreateDescriptorPool(ctx->device(), &dpci, nullptr, &particleDescPools_[f]), + "vkCreateDescriptorPool(particle)"); + } + + VkShaderModuleCreateInfo vsmci{}; + vsmci.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + vsmci.codeSize = sizeof(kParticleVertSpv); + vsmci.pCode = kParticleVertSpv; + VkShaderModule vert = VK_NULL_HANDLE; + check(vkCreateShaderModule(ctx->device(), &vsmci, nullptr, &vert), + "vkCreateShaderModule(particle.vert)"); + VkShaderModuleCreateInfo fsmci{}; + fsmci.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + fsmci.codeSize = sizeof(kParticleFragSpv); + fsmci.pCode = kParticleFragSpv; + VkShaderModule frag = VK_NULL_HANDLE; + check(vkCreateShaderModule(ctx->device(), &fsmci, nullptr, &frag), + "vkCreateShaderModule(particle.frag)"); + + VkPipelineShaderStageCreateInfo stages[2]{}; + stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + stages[0].module = vert; + stages[0].pName = "main"; + stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + stages[1].module = frag; + stages[1].pName = "main"; + + // 4 separate vertex bindings: pos(0), normal(1), uv(2), color(3). + VkVertexInputBindingDescription vibs[4]{}; + vibs[0].binding = 0; vibs[0].stride = 3 * sizeof(float); vibs[0].inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + vibs[1].binding = 1; vibs[1].stride = 3 * sizeof(float); vibs[1].inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + vibs[2].binding = 2; vibs[2].stride = 2 * sizeof(float); vibs[2].inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + vibs[3].binding = 3; vibs[3].stride = 3 * sizeof(float); vibs[3].inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + VkVertexInputAttributeDescription vias[4]{}; + vias[0].location = 0; vias[0].binding = 0; vias[0].format = VK_FORMAT_R32G32B32_SFLOAT; vias[0].offset = 0; + vias[1].location = 1; vias[1].binding = 1; vias[1].format = VK_FORMAT_R32G32B32_SFLOAT; vias[1].offset = 0; + vias[2].location = 2; vias[2].binding = 2; vias[2].format = VK_FORMAT_R32G32_SFLOAT; vias[2].offset = 0; + vias[3].location = 3; vias[3].binding = 3; vias[3].format = VK_FORMAT_R32G32B32_SFLOAT; vias[3].offset = 0; + VkPipelineVertexInputStateCreateInfo vi{}; + vi.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + vi.vertexBindingDescriptionCount = 4; + vi.pVertexBindingDescriptions = vibs; + vi.vertexAttributeDescriptionCount = 4; + vi.pVertexAttributeDescriptions = vias; + + VkPipelineInputAssemblyStateCreateInfo ia{}; + ia.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; + ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + + VkPipelineViewportStateCreateInfo vp{}; + vp.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; + vp.viewportCount = 1; + vp.scissorCount = 1; + + // Side::Double — particles are billboards, draw both faces. + VkPipelineRasterizationStateCreateInfo rs{}; + rs.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; + rs.polygonMode = VK_POLYGON_MODE_FILL; + rs.cullMode = VK_CULL_MODE_NONE; + rs.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + rs.lineWidth = 1.0f; + + VkPipelineMultisampleStateCreateInfo ms{}; + ms.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; + ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + + VkDynamicState dynStates[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dyn{}; + dyn.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + dyn.dynamicStateCount = 2; + dyn.pDynamicStates = dynStates; + + const VkFormat colorFmt = ctx->swapchainFormat(); + VkPipelineRenderingCreateInfo prci{}; + prci.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO; + prci.colorAttachmentCount = 1; + prci.pColorAttachmentFormats = &colorFmt; + prci.depthAttachmentFormat = VK_FORMAT_D32_SFLOAT; + + // ── Normal variant: non-premultiplied alpha, depth-tested ────────── + // depthTest GREATER_OR_EQUAL (reverse-Z) so particles are occluded by + // scene geometry; depthWrite OFF (transparent, read-only unjitDepth). + VkPipelineDepthStencilStateCreateInfo dsNormal{}; + dsNormal.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; + dsNormal.depthTestEnable = VK_TRUE; + dsNormal.depthWriteEnable = VK_FALSE; + dsNormal.depthCompareOp = VK_COMPARE_OP_GREATER_OR_EQUAL; + + VkPipelineColorBlendAttachmentState cbasAlpha{}; + cbasAlpha.blendEnable = VK_TRUE; + cbasAlpha.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + cbasAlpha.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + cbasAlpha.colorBlendOp = VK_BLEND_OP_ADD; + cbasAlpha.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + cbasAlpha.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + cbasAlpha.alphaBlendOp = VK_BLEND_OP_ADD; + cbasAlpha.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + VkPipelineColorBlendStateCreateInfo cbAlpha{}; + cbAlpha.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; + cbAlpha.attachmentCount = 1; + cbAlpha.pAttachments = &cbasAlpha; + + VkGraphicsPipelineCreateInfo gpci{}; + gpci.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + gpci.pNext = &prci; + gpci.stageCount = 2; + gpci.pStages = stages; + gpci.pVertexInputState = &vi; + gpci.pInputAssemblyState = &ia; + gpci.pViewportState = &vp; + gpci.pRasterizationState = &rs; + gpci.pMultisampleState = &ms; + gpci.pDepthStencilState = &dsNormal; + gpci.pColorBlendState = &cbAlpha; + gpci.pDynamicState = &dyn; + gpci.layout = particlePipelineLayout_; + check(vkCreateGraphicsPipelines(ctx->device(), ctx->pipelineCache(), 1, &gpci, nullptr, + &particlePipelineNormal_), + "vkCreateGraphicsPipelines(particleNormal)"); + + // ── Additive variant: src·srcAlpha + dst, depth-test OFF ─────────── + // Matches ParticleSystem's depthTest=false for non-Normal blending + // (fireball / firework draw over the scene). + VkPipelineDepthStencilStateCreateInfo dsAdd = dsNormal; + dsAdd.depthTestEnable = VK_FALSE; + VkPipelineColorBlendAttachmentState cbasAdd = cbasAlpha; + cbasAdd.dstColorBlendFactor = VK_BLEND_FACTOR_ONE; + cbasAdd.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + cbasAdd.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + VkPipelineColorBlendStateCreateInfo cbAdd = cbAlpha; + cbAdd.pAttachments = &cbasAdd; + VkGraphicsPipelineCreateInfo gpciAdd = gpci; + gpciAdd.pDepthStencilState = &dsAdd; + gpciAdd.pColorBlendState = &cbAdd; + check(vkCreateGraphicsPipelines(ctx->device(), ctx->pipelineCache(), 1, &gpciAdd, nullptr, + &particlePipelineAdditive_), + "vkCreateGraphicsPipelines(particleAdditive)"); + + vkDestroyShaderModule(ctx->device(), vert, nullptr); + vkDestroyShaderModule(ctx->device(), frag, nullptr); + + ensureParticleWhiteTexture(); + createSpriteWorldPipeline(); + } + + // World-space Sprite billboard pipeline. Perspective billboard + // (sprite3d.vert) + the shared overlay_sprite.frag, alpha-blended and + // depth-tested against unjitDepth (occluded by scene geometry, + // depth-write off). Reuses particlePipelineLayout_ (128B SpritePC + set-0 + // sampler) and builds the shared static quad. Called from + // createParticlePipeline so the shared layout already exists. + void createSpriteWorldPipeline() { + if (spriteWorldPipeline_ != VK_NULL_HANDLE) return; + + // Shared canonical quad: 4 interleaved (pos.xyz, uv.xy) verts + + // 6 indices — matches Sprite's geometry (src/objects/Sprite.cpp). + { + static const float quad[] = { + -0.5f, -0.5f, 0.f, 0.f, 0.f, + 0.5f, -0.5f, 0.f, 1.f, 0.f, + 0.5f, 0.5f, 0.f, 1.f, 1.f, + -0.5f, 0.5f, 0.f, 0.f, 1.f}; + static const uint32_t idx[] = {0, 1, 2, 0, 2, 3}; + spriteQuadVtx_ = createBuffer( + ctx->allocator(), ctx->device(), sizeof(quad), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_AUTO, + VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT); + spriteQuadIdx_ = createBuffer( + ctx->allocator(), ctx->device(), sizeof(idx), + VK_BUFFER_USAGE_INDEX_BUFFER_BIT, VMA_MEMORY_USAGE_AUTO, + VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT); + void* m = nullptr; + vmaMapMemory(ctx->allocator(), spriteQuadVtx_.alloc, &m); + std::memcpy(m, quad, sizeof(quad)); + vmaUnmapMemory(ctx->allocator(), spriteQuadVtx_.alloc); + vmaMapMemory(ctx->allocator(), spriteQuadIdx_.alloc, &m); + std::memcpy(m, idx, sizeof(idx)); + vmaUnmapMemory(ctx->allocator(), spriteQuadIdx_.alloc); + } + + VkShaderModuleCreateInfo vsmci{}; + vsmci.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + vsmci.codeSize = sizeof(kSprite3dVertSpv); + vsmci.pCode = kSprite3dVertSpv; + VkShaderModule vert = VK_NULL_HANDLE; + check(vkCreateShaderModule(ctx->device(), &vsmci, nullptr, &vert), + "vkCreateShaderModule(sprite3d.vert)"); + VkShaderModuleCreateInfo fsmci{}; + fsmci.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + fsmci.codeSize = sizeof(kOverlaySpriteFragSpv); + fsmci.pCode = kOverlaySpriteFragSpv; + VkShaderModule frag = VK_NULL_HANDLE; + check(vkCreateShaderModule(ctx->device(), &fsmci, nullptr, &frag), + "vkCreateShaderModule(overlay_sprite.frag for sprite3d)"); + + VkPipelineShaderStageCreateInfo stages[2]{}; + stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + stages[0].module = vert; + stages[0].pName = "main"; + stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + stages[1].module = frag; + stages[1].pName = "main"; + + // One interleaved binding: pos.xyz at 0, uv.xy at offset 12. + VkVertexInputBindingDescription vib{}; + vib.binding = 0; + vib.stride = 5 * sizeof(float); + vib.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + VkVertexInputAttributeDescription vias[2]{}; + vias[0].location = 0; vias[0].binding = 0; vias[0].format = VK_FORMAT_R32G32B32_SFLOAT; vias[0].offset = 0; + vias[1].location = 1; vias[1].binding = 0; vias[1].format = VK_FORMAT_R32G32_SFLOAT; vias[1].offset = 3 * sizeof(float); + VkPipelineVertexInputStateCreateInfo vi{}; + vi.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + vi.vertexBindingDescriptionCount = 1; + vi.pVertexBindingDescriptions = &vib; + vi.vertexAttributeDescriptionCount = 2; + vi.pVertexAttributeDescriptions = vias; + + VkPipelineInputAssemblyStateCreateInfo ia{}; + ia.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; + ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + + VkPipelineViewportStateCreateInfo vp{}; + vp.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; + vp.viewportCount = 1; + vp.scissorCount = 1; + + VkPipelineRasterizationStateCreateInfo rs{}; + rs.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; + rs.polygonMode = VK_POLYGON_MODE_FILL; + rs.cullMode = VK_CULL_MODE_NONE; + rs.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + rs.lineWidth = 1.0f; + + VkPipelineMultisampleStateCreateInfo ms{}; + ms.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; + ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + + // Depth-tested (occluded by scene), depth-write off (transparent). + VkPipelineDepthStencilStateCreateInfo ds{}; + ds.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; + ds.depthTestEnable = VK_TRUE; + ds.depthWriteEnable = VK_FALSE; + ds.depthCompareOp = VK_COMPARE_OP_GREATER_OR_EQUAL;// reverse-Z + + VkPipelineColorBlendAttachmentState cbas{}; + cbas.blendEnable = VK_TRUE; + cbas.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + cbas.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + cbas.colorBlendOp = VK_BLEND_OP_ADD; + cbas.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + cbas.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + cbas.alphaBlendOp = VK_BLEND_OP_ADD; + cbas.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + VkPipelineColorBlendStateCreateInfo cb{}; + cb.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; + cb.attachmentCount = 1; + cb.pAttachments = &cbas; + + VkDynamicState dynStates[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dyn{}; + dyn.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + dyn.dynamicStateCount = 2; + dyn.pDynamicStates = dynStates; + + const VkFormat colorFmt = ctx->swapchainFormat(); + VkPipelineRenderingCreateInfo prci{}; + prci.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO; + prci.colorAttachmentCount = 1; + prci.pColorAttachmentFormats = &colorFmt; + prci.depthAttachmentFormat = VK_FORMAT_D32_SFLOAT; + + VkGraphicsPipelineCreateInfo gpci{}; + gpci.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + gpci.pNext = &prci; + gpci.stageCount = 2; + gpci.pStages = stages; + gpci.pVertexInputState = &vi; + gpci.pInputAssemblyState = &ia; + gpci.pViewportState = &vp; + gpci.pRasterizationState = &rs; + gpci.pMultisampleState = &ms; + gpci.pDepthStencilState = &ds; + gpci.pColorBlendState = &cb; + gpci.pDynamicState = &dyn; + gpci.layout = particlePipelineLayout_;// 128B SpritePC + set-0 sampler + check(vkCreateGraphicsPipelines(ctx->device(), ctx->pipelineCache(), 1, &gpci, nullptr, + &spriteWorldPipeline_), + "vkCreateGraphicsPipelines(spriteWorld)"); + + vkDestroyShaderModule(ctx->device(), vert, nullptr); + vkDestroyShaderModule(ctx->device(), frag, nullptr); + } + + // Walk the scene for visible world-space Sprites (screenSpace == false) + // with a texture map, snapshotting their world transform + material into + // lastVisibleSprites_. Run every perspective frame (sprites move / spawn + // / expire constantly — no snapshot caching). Mirrors OverlayPass's + // sprite collection, minus the screen-space branch. + void collectWorldSprites(Object3D& scene) { + lastVisibleSprites_.clear(); + scene.traverseVisible([&](Object3D& o) { + auto* sp = dynamic_cast(&o); + if (!sp || sp->screenSpace) return; + auto mat = sp->material(); + if (!mat || !mat->visible) return; + auto* mm = dynamic_cast(mat.get()); + if (!mm || !mm->map) return;// untextured world sprites aren't drawn + WorldSpriteEntry e{}; + std::memcpy(e.world.data(), sp->matrixWorld->elements.data(), 64); + e.color = {1.f, 1.f, 1.f, 1.f}; + if (auto* mc = dynamic_cast(mat.get())) { + e.color[0] = mc->color.r; + e.color[1] = mc->color.g; + e.color[2] = mc->color.b; + } + e.color[3] = mat->opacity; + if (auto* mr = dynamic_cast(mat.get())) { + e.rotation = mr->rotation; + } + e.center = sp->center; + e.tex = mm->map.get(); + lastVisibleSprites_.push_back(e); + }); + } + // Line geometry cache shared between the ortho overlay (via // OverlayPass::record) and the 3D hybrid overlay // (recordCommandBuffer's line-draw section). Keyed on raw @@ -9848,6 +10574,9 @@ namespace threepp { if (overlayWireframePipeline == VK_NULL_HANDLE) { createOverlayPipeline(); } + if (particlePipelineNormal_ == VK_NULL_HANDLE) { + createParticlePipeline(); + } // Raster G-buffer matches the PT render extent — in hybrid mode // raygen reads it 1:1 by launch coord, so it must launch at the // same resolution the gbuffer rasterized at. @@ -13365,6 +14094,10 @@ namespace threepp { for (size_t i = 0; i < lastVisibleEntries_.size(); ++i) { const auto& en = lastVisibleEntries_[i]; if (!en.mesh || !en.isOverlay) continue; + // Particle billboards are isOverlay but drawn by the + // dedicated billboard loop below — their un-expanded + // quads would render as zero-area triangles here. + if (en.isParticle) continue; Color color(1.f, 1.f, 1.f); float opacity = 1.0f; bool wireframe = false; @@ -13545,6 +14278,198 @@ namespace threepp { } } + // ── Particle billboards ──────────────────────────────── + // ParticleSystem meshes (isParticle) are drawn here, after + // the wireframe/line/point overlays, in the same render-pass + // instance (reuses the viewport/scissor set above — split- + // screen aware). Each is a billboard quad expanded in + // particle.vert from per-vertex {size,angle,opacity}; depth- + // tested against unjitDepth (Normal) or unconditionally drawn + // (Additive). The vertex data lives in particleGeomCache_, the + // texture in particleTexCache_ (white default if untextured). + if (particlePipelineNormal_ != VK_NULL_HANDLE) { + bool anyParticle = false; + for (const auto& en : lastVisibleEntries_) { + if (en.isParticle && en.mesh) { anyParticle = true; break; } + } + const bool anySprite = !lastVisibleSprites_.empty(); + if (anyParticle || anySprite) { + // Shared per-frame descriptor pool + unjittered camera + // for both billboard kinds (particles + world sprites). + vkResetDescriptorPool(ctx->device(), particleDescPools_[currentFrame], 0); + Matrix4 viewM, projM; + std::memcpy(viewM.elements.data(), currViewUnjit_.data(), 64); + std::memcpy(projM.elements.data(), currProjUnjit_.data(), 64); + VkPipeline curParticlePipe = VK_NULL_HANDLE; + + for (const auto& en : lastVisibleEntries_) { + if (!en.isParticle || !en.mesh) continue; + auto geomSp = en.mesh->geometry(); + const ParticleGeomRec* prec = ensureParticleGeom(geomSp); + if (!prec) continue; + + auto matPtr = en.mesh->material(); + auto* sm = dynamic_cast(matPtr.get()); + if (!sm) continue; + const bool additive = (sm->blending != Blending::Normal); + const Texture* tex = nullptr; + if (auto uit = sm->uniforms.find("tex"); + uit != sm->uniforms.end() && uit->second.hasValue()) { + if (auto** t = std::get_if(&uit->second.value())) { + tex = *t; + } + } + + VkPipeline want = additive ? particlePipelineAdditive_ + : particlePipelineNormal_; + if (want != curParticlePipe) { + vkCmdBindPipeline(cb, VK_PIPELINE_BIND_POINT_GRAPHICS, want); + curParticlePipe = want; + } + + // Resolve the texture (or white default) into a + // per-draw descriptor set from this frame's pool. + const Image2D* texImg = ensureParticleTexture(tex); + if (!texImg) texImg = &particleWhiteTex_; + VkDescriptorSetAllocateInfo asi{}; + asi.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + asi.descriptorPool = particleDescPools_[currentFrame]; + asi.descriptorSetCount = 1; + asi.pSetLayouts = &particleDescSetLayout_; + VkDescriptorSet set = VK_NULL_HANDLE; + if (vkAllocateDescriptorSets(ctx->device(), &asi, &set) != VK_SUCCESS) continue; + VkDescriptorImageInfo dii{}; + dii.imageView = texImg->view; + dii.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + dii.sampler = texImg->sampler; + VkWriteDescriptorSet w{}; + w.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w.dstSet = set; + w.dstBinding = 0; + w.descriptorCount = 1; + w.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w.pImageInfo = &dii; + vkUpdateDescriptorSets(ctx->device(), 1, &w, 0, nullptr); + vkCmdBindDescriptorSets(cb, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 0, 1, &set, 0, nullptr); + + Matrix4 modelM, mvM; + std::memcpy(modelM.elements.data(), en.worldMatrix.data(), 64); + mvM.multiplyMatrices(viewM, modelM); + struct ParticlePC { + float modelView[16]; + float proj[16]; + } pc{}; + std::memcpy(pc.modelView, mvM.elements.data(), 64); + std::memcpy(pc.proj, projM.elements.data(), 64); + vkCmdPushConstants(cb, particlePipelineLayout_, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(pc), &pc); + + VkBuffer vbufs[4] = {prec->position.handle, prec->normal.handle, + prec->uv.handle, prec->color.handle}; + VkDeviceSize voffs[4] = {0, 0, 0, 0}; + vkCmdBindVertexBuffers(cb, 0, 4, vbufs, voffs); + vkCmdBindIndexBuffer(cb, prec->index.handle, 0, VK_INDEX_TYPE_UINT32); + vkCmdDrawIndexed(cb, prec->indexCount, 1, 0, 0, 0); + } + + // ── World-space sprites ──────────────────────── + // Camera-facing textured billboards positioned in 3D + // (TPS impact particles etc.). Shared static quad + + // per-sprite push constant (SpritePC). One descriptor + // set per unique texture this frame (deduped) since a + // burst shares one material/map across many sprites. + if (anySprite && spriteWorldPipeline_ != VK_NULL_HANDLE) { + vkCmdBindPipeline(cb, VK_PIPELINE_BIND_POINT_GRAPHICS, spriteWorldPipeline_); + VkBuffer qv[1] = {spriteQuadVtx_.handle}; + VkDeviceSize qo[1] = {0}; + vkCmdBindVertexBuffers(cb, 0, 1, qv, qo); + vkCmdBindIndexBuffer(cb, spriteQuadIdx_.handle, 0, VK_INDEX_TYPE_UINT32); + + std::unordered_map setCache; + const Texture* curTex = nullptr; + bool curTexBound = false; + for (const auto& sd : lastVisibleSprites_) { + // Resolve (and cache) the descriptor set for + // this sprite's texture; white default on miss. + if (!curTexBound || sd.tex != curTex) { + VkDescriptorSet set = VK_NULL_HANDLE; + auto cIt = setCache.find(sd.tex); + if (cIt != setCache.end()) { + set = cIt->second; + } else { + const Image2D* texImg = ensureParticleTexture(sd.tex); + if (!texImg) texImg = &particleWhiteTex_; + VkDescriptorSetAllocateInfo asi{}; + asi.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + asi.descriptorPool = particleDescPools_[currentFrame]; + asi.descriptorSetCount = 1; + asi.pSetLayouts = &particleDescSetLayout_; + if (vkAllocateDescriptorSets(ctx->device(), &asi, &set) != VK_SUCCESS) continue; + VkDescriptorImageInfo dii{}; + dii.imageView = texImg->view; + dii.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + dii.sampler = texImg->sampler; + VkWriteDescriptorSet w{}; + w.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w.dstSet = set; + w.dstBinding = 0; + w.descriptorCount = 1; + w.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w.pImageInfo = &dii; + vkUpdateDescriptorSets(ctx->device(), 1, &w, 0, nullptr); + setCache.emplace(sd.tex, set); + } + vkCmdBindDescriptorSets(cb, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 0, 1, &set, 0, nullptr); + curTex = sd.tex; + curTexBound = true; + } + + // modelView = view · spriteWorld; the billboard + // is rebuilt camera-facing in sprite3d.vert from + // the view-space center + world scale. + Matrix4 modelM, mvM; + std::memcpy(modelM.elements.data(), sd.world.data(), 64); + mvM.multiplyMatrices(viewM, modelM); + Vector3 worldScale; + worldScale.setFromMatrixScale(modelM); + Vector3 mvPos; + mvPos.setFromMatrixPosition(mvM); + + struct SpritePC { + float projection[16]; + float mvPos[4]; + float scale[2]; + float center[2]; + float color[4]; + float rotation; + float pad[3]; + } pc{}; + std::memcpy(pc.projection, projM.elements.data(), 64); + pc.mvPos[0] = static_cast(mvPos.x); + pc.mvPos[1] = static_cast(mvPos.y); + pc.mvPos[2] = static_cast(mvPos.z); + pc.mvPos[3] = 1.f; + pc.scale[0] = static_cast(worldScale.x); + pc.scale[1] = static_cast(worldScale.y); + pc.center[0] = sd.center.x; + pc.center[1] = sd.center.y; + pc.color[0] = sd.color[0]; + pc.color[1] = sd.color[1]; + pc.color[2] = sd.color[2]; + pc.color[3] = sd.color[3]; + pc.rotation = sd.rotation; + vkCmdPushConstants(cb, particlePipelineLayout_, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(pc), &pc); + vkCmdDrawIndexed(cb, 6, 1, 0, 0, 0); + } + } + } + } + vkCmdEndRendering(cb); // Swapchain back to GENERAL so the downstream blocks @@ -14443,6 +15368,12 @@ namespace threepp { if (!camera.is()) { const auto sceneStart = std::chrono::high_resolution_clock::now(); pimpl_->ensureSceneBuilt(scene); + // World-space Sprites (screenSpace == false) are drawn by the overlay + // billboard pass, not the PT/G-buffer path. Snapshot them each frame + // with fresh world matrices (ensureSceneBuilt just ran + // updateMatrixWorld) — independent of the snapshot/lean machinery, + // since impact sprites move/spawn/expire every frame. + pimpl_->collectWorldSprites(scene); pimpl_->pendingCpuEnsureSceneMs_ = std::chrono::duration( std::chrono::high_resolution_clock::now() - sceneStart) diff --git a/src/threepp/renderers/vulkan/shaders/particle.frag b/src/threepp/renderers/vulkan/shaders/particle.frag new file mode 100644 index 00000000..e532d35b --- /dev/null +++ b/src/threepp/renderers/vulkan/shaders/particle.frag @@ -0,0 +1,32 @@ +#version 460 + +// Particle billboard fragment shader. Mirrors the GL ParticleSystem fragment +// shader (modulate per-particle vertex color × particle texture) and the HUD +// overlay_sprite.frag color convention. +// +// The vertex color (HSL→RGB) and the texture are LINEAR (an sRGB-tagged texture +// is hardware-decoded to linear on sample). The swapchain is B8G8R8A8_UNORM with +// no hardware sRGB write-out and the path-traced image is sRGB-encoded by +// denoise.comp, so we apply the same linear→sRGB OETF here. Without it particles +// composite darker/more-saturated than the rest of the frame. Blending (alpha vs +// additive) is set by the pipeline variant, not here. Untextured particle systems +// bind a 1×1 white default so the sampler is always valid. + +layout(set = 0, binding = 0) uniform sampler2D tex; + +layout(location = 0) in vec4 vColor; +layout(location = 1) in vec2 vUv; + +layout(location = 0) out vec4 outColor; + +vec3 linearToSRGB(vec3 x) { + const vec3 cutoff = vec3(lessThan(x, vec3(0.0031308))); + const vec3 lower = 12.92 * x; + const vec3 higher = 1.055 * pow(max(x, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; + return mix(higher, lower, cutoff); +} + +void main() { + vec4 t = texture(tex, vUv); + outColor = vec4(linearToSRGB(vColor.rgb * t.rgb), vColor.a * t.a); +} diff --git a/src/threepp/renderers/vulkan/shaders/particle.vert b/src/threepp/renderers/vulkan/shaders/particle.vert new file mode 100644 index 00000000..ea0ab3d3 --- /dev/null +++ b/src/threepp/renderers/vulkan/shaders/particle.vert @@ -0,0 +1,64 @@ +#version 460 + +// World-space particle billboard vertex shader. Vulkan port of the GL/WGPU +// ParticleSystem shader (src/threepp/objects/ParticleSystem.cpp): the renderer +// has no generic ShaderMaterial path, so the particle Mesh is drawn here by a +// dedicated billboard pass in the post-TAA overlay block rather than the +// PT/G-buffer path. +// +// The geometry stores 4 coincident verts per particle (all at the particle +// CENTER); the quad is expanded HERE from the per-vertex data: +// inPos = particle center (model space) +// inNormal = {size, angle, opacity} +// inUv = quad corner offset, -0.5..0.5 +// inColor = particle RGB +// +// CPU pushes modelView (= viewUnjittered · meshWorld) and the reverse-Z +// projection separately because the distance-attenuated billboard scale needs +// view-space depth and proj[1][1] individually (it can't use a combined MVP). + +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec3 inNormal; +layout(location = 2) in vec2 inUv; +layout(location = 3) in vec3 inColor; + +layout(location = 0) out vec4 vColor; +layout(location = 1) out vec2 vUv; + +layout(push_constant) uniform Pc { + mat4 modelView; // 64 — viewUnjittered · meshWorld + mat4 proj; // 64 — reverse-Z Vulkan projection (unjittered) +} pc; + +void main() { + float pSize = inNormal.x; + float pAngle = inNormal.y; + float pOpacity = inNormal.z; + + vColor = vec4(inColor, pOpacity); + + // Rotate the corner offset by the particle angle (screen-aligned). + float c = cos(pAngle); + float s = sin(pAngle); + vec2 rotated = vec2(c * inUv.x - s * inUv.y, + s * inUv.x + c * inUv.y); + + vec4 mvPosition = pc.modelView * vec4(inPos, 1.0); + vec4 clipPos = pc.proj * mvPosition; + + // proj[1][1] is the Y scale (≈ 1/tan(fov/2)); reverse-Z only rewrites the + // depth row, so this matches the GL billboard scale term exactly. Multiply + // by clipPos.w to offset in clip space (perspective-correct billboard size). + float scale = pSize * pc.proj[1][1] / length(mvPosition.xyz); + clipPos.xy += rotated * scale * clipPos.w; + + // threepp projection follows the GL NDC convention (Y up); Vulkan NDC is Y + // down, so flip Y at the clip boundary — same correction overlay.vert + // applies. The reverse-Z projection already maps z to [0,1], so no z-remap + // is needed (unlike the ortho overlay_sprite path). + clipPos.y = -clipPos.y; + gl_Position = clipPos; + + // Texture UV derived from the corner offset (matches GL: uv + 0.5). + vUv = inUv + vec2(0.5); +} diff --git a/src/threepp/renderers/vulkan/shaders/sprite3d.vert b/src/threepp/renderers/vulkan/shaders/sprite3d.vert new file mode 100644 index 00000000..7a483f5b --- /dev/null +++ b/src/threepp/renderers/vulkan/shaders/sprite3d.vert @@ -0,0 +1,51 @@ +#version 460 + +// World-space Sprite billboard vertex shader. Vulkan port of the GL/WGPU sprite +// path for sprites with screenSpace == false (3D-positioned camera-facing quads — +// e.g. the TPS shooter's impact "particles"). The screen-space HUD sprites go +// through overlay_sprite.vert (ortho); this is the perspective, depth-tested +// counterpart drawn in the post-TAA overlay block. +// +// Same push-constant layout as overlay_sprite (SpritePC) so the fragment shader +// (overlay_sprite.frag) is shared. The ONLY difference from overlay_sprite.vert: +// the projection here is the perspective reverse-Z matrix, which already maps z +// to Vulkan's [0,w] — so we DROP the GL→Vulkan z-remap (that remap is only +// correct for the GL-convention ortho HUD projection). + +layout(location = 0) in vec3 inPos; // local quad position, -0.5..0.5 +layout(location = 1) in vec2 inUv; // 0..1 corner UVs + +layout(location = 0) out vec2 vUv; + +layout(push_constant) uniform Pc { + mat4 projection; // 64 — perspective reverse-Z projection (unjittered) + vec4 mvPos; // 16 — sprite center in view space (xyz used) + vec2 scale; // 8 — world-space scale extracted from modelMatrix + vec2 center; // 8 — pivot offset (0..1), matches Sprite::center + vec4 color; // 16 — tint rgb + opacity (.a) + float rotation; // 4 — radians, SpriteMaterial.rotation + float _pad0; // 4 + float _pad1; // 4 + float _pad2; // 4 +} pc; + +void main() { + vec2 aligned = ((inPos.xy - pc.center) + vec2(0.5)) * pc.scale; + float s = sin(pc.rotation); + float c = cos(pc.rotation); + vec2 rotated = vec2(c * aligned.x - s * aligned.y, + s * aligned.x + c * aligned.y); + // Camera-facing: offset the view-space center in view XY, then project. The + // offset is in view (≈ world) units, so the sprite keeps a constant world + // size and shrinks with distance under perspective (sizeAttenuation). + vec4 view = vec4(pc.mvPos.xyz, 1.0); + view.xy += rotated; + vec4 clip = pc.projection * view; + // threepp projection follows the GL NDC convention (Y up); Vulkan NDC is Y + // down, so flip Y at the clip boundary (same as overlay.vert / particle.vert). + // The reverse-Z projection already maps z to [0,w], so — unlike the ortho + // overlay_sprite path — NO z-remap is applied. + clip.y = -clip.y; + gl_Position = clip; + vUv = inUv; +}