Files
qbz/crates/qbz-qt/cxx/tunnelflow_item.cpp
T
vicrodh e6c058c2f2 feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral
Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven
ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed
by the Viz16 stream. Scene rows live in the header view menu (no Off row);
g cycles the ring. A fresh immersive open defaults to Split > Lyrics
(owner ruling).

OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as
type buf and type buf"): every ShaderEffect fragment with its own uniform
layout now ships a paired vertex stage declaring the identical block —
the shared spectrum.vert only ever linked under Vulkan.

RHI hardening: render pass descriptors on the ping-pong targets (the
Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the
backend (!== OpenGL), ribbon sub-uploads carry setSourceSize.

km
2026-08-16 08:22:57 -06:00

416 lines
17 KiB
C++

// Tunnel Flow (immersive shader scene, mode 8 — Qt-only) — the QRhi
// renderer behind TunnelFlowItem. Block B1 of the 2026-08-15
// immersive-completion contract (spec 02-tauri-tunnel-port.md). The
// mechanism is the PlasmaItem one (cxx/plasma_item.cpp carries the full
// rationale): a PING-PONG over two owned RGBA8 textures — pass 1 renders the
// tunnel into history[write] sampling history[read] (the source panel's
// self-drawImage trail system), pass 2 blits history[write] into the item's
// render target, then the indices swap. Owning both targets means the item's
// texture is never read back and no Qt-internal attachment is touched. The
// blit reuses plasma_blit.frag.qsb (a pure 1:1 textured triangle).
//
// Resource cadence (the pulse law): `synchronize` snapshots the QML-side
// properties when the item updates (pulse tick only); per frame the GPU work
// is one uniform upload + two fullscreen triangles.
//
// History size tracks the item's physical size CAPPED at 2560x1440 (the
// plasma_item / ShaderSceneLayer resW/resH rule); on a bigger screen the
// blit upscales. A resize rebuilds the pair and re-clears it (the trails
// restart).
#include "tunnelflow_item.h"
#include <QtCore/QCoreApplication>
#include <QtCore/QFile>
#include <QtQml/qqml.h>
#include <rhi/qrhi.h>
#include <cstring>
namespace {
// The history-target ceiling (see the header).
constexpr int MAX_W = 2560;
constexpr int MAX_H = 1440;
// The scene's own std140 block — NOT the 144-byte spec-01 block: Tunnel Flow
// consumes the Viz16 stream (smoothed in QML), not the shader pack, so the
// block holds exactly what tunnel_flow.frag reads. vec4-aligned.
struct UniformData
{
float time; // 0 (seconds)
float phase; // 4
float bass; // 8
float mid; // 12
float high; // 16
float kick; // 20
float res[2]; // 24
float palette0[4]; // 32
float palette1[4]; // 48
float palette2[4]; // 64
float palette3[4]; // 80
};
static_assert(sizeof(UniformData) == 96, "std140 tunnel-flow block");
QShader loadShader(const QString &qrcPath)
{
QFile f(qrcPath);
if (f.open(QIODevice::ReadOnly)) {
const QShader s = QShader::fromSerialized(f.readAll());
if (!s.isValid())
qWarning("[tunnelflow] shader %s failed to deserialize", qPrintable(qrcPath));
return s;
}
qWarning("[tunnelflow] shader %s not found in the qrc", qPrintable(qrcPath));
return QShader();
}
QVector4D colorVec(const QColor &c)
{
return QVector4D(float(c.redF()), float(c.greenF()), float(c.blueF()), 1.0f);
}
class TunnelFlowRenderer : public QQuickRhiItemRenderer
{
public:
~TunnelFlowRenderer() override
{
// The render pass descriptors are OURS (the RTs borrow them).
delete m_histRpDesc[0];
delete m_histRpDesc[1];
}
void initialize(QRhiCommandBuffer *cb) override
{
QRhi *r = cb->rhi();
if (m_vbuf) // already built (initialize can re-run on rhi changes;
return; // the rebuild path drops everything first)
// The fullscreen triangle (rhi_triangle.vert replaces gl_VertexID
// with this buffer — the GLES 3.0-portable spelling, plasma idiom).
// Shared by the feedback AND blit passes.
static const float TRI[] = { -1.0f, -1.0f, 3.0f, -1.0f, -1.0f, 3.0f };
m_vbuf.reset(r->newBuffer(QRhiBuffer::Immutable,
QRhiBuffer::VertexBuffer,
quint32(sizeof(TRI))));
m_ubuf.reset(r->newBuffer(QRhiBuffer::Dynamic,
QRhiBuffer::UniformBuffer,
quint32(sizeof(UniformData))));
m_sampler.reset(r->newSampler(QRhiSampler::Linear,
QRhiSampler::Linear,
QRhiSampler::None,
QRhiSampler::ClampToEdge,
QRhiSampler::ClampToEdge));
if (!m_vbuf->create() || !m_ubuf->create() || !m_sampler->create()) {
qWarning("[tunnelflow] resource creation failed — the scene stays dark");
m_vbuf.reset();
return;
}
QRhiResourceUpdateBatch *u = r->nextResourceUpdateBatch();
u->uploadStaticBuffer(m_vbuf.get(), TRI);
cb->resourceUpdate(u);
}
void synchronize(QQuickRhiItem *item) override
{
// GUI thread: snapshot the item's properties. Runs on item update()
// (the pulse tick) and on geometry/rhi changes.
auto *tf = static_cast<TunnelFlowItem *>(item);
m_time = tf->m_time;
m_phase = tf->m_phase;
m_bass = tf->m_bass;
m_mid = tf->m_mid;
m_high = tf->m_high;
m_kick = tf->m_kick;
m_palette0 = colorVec(tf->m_palette0);
m_palette1 = colorVec(tf->m_palette1);
m_palette2 = colorVec(tf->m_palette2);
m_palette3 = colorVec(tf->m_palette3);
}
void render(QRhiCommandBuffer *cb) override
{
if (!m_vbuf)
return; // resource creation failed in initialize
QRhiRenderTarget *rt = renderTarget();
const QSize ps = rt->pixelSize();
if (ps.isEmpty())
return;
const QSize hist = capped(ps);
if (hist != m_histSize)
rebuildHistory(hist);
if (!m_hist[0])
return; // history creation failed — stay dark
// Pipeline A (scene) renders into the history pair's pass; pipeline
// B (blit) into the item's. Rebuild each when its descriptor moves.
if (!m_scenePipe || m_histRt[0]->renderPassDescriptor() != m_scenePassDescriptor) {
m_scenePassDescriptor = m_histRt[0]->renderPassDescriptor();
buildScenePipeline();
if (!m_scenePipe)
return;
}
if (!m_blitPipe || rt->renderPassDescriptor() != m_blitPassDescriptor) {
m_blitPassDescriptor = rt->renderPassDescriptor();
buildBlitPipeline();
if (!m_blitPipe)
return;
}
QRhiResourceUpdateBatch *u = rhi()->nextResourceUpdateBatch();
UniformData ud = {};
ud.time = m_time;
ud.phase = m_phase;
ud.bass = m_bass;
ud.mid = m_mid;
ud.high = m_high;
ud.kick = m_kick;
ud.res[0] = float(hist.width());
ud.res[1] = float(hist.height());
std::memcpy(ud.palette0, &m_palette0, sizeof(ud.palette0));
std::memcpy(ud.palette1, &m_palette1, sizeof(ud.palette1));
std::memcpy(ud.palette2, &m_palette2, sizeof(ud.palette2));
std::memcpy(ud.palette3, &m_palette3, sizeof(ud.palette3));
u->updateDynamicBuffer(m_ubuf.get(), 0, quint32(sizeof(ud)), &ud);
// A fresh pair starts BLACK (the source panel's canvas starts blank
// too — an unsampled-cleared first frame would advect garbage).
if (m_needClear) {
m_needClear = false;
for (int i = 0; i < 2; ++i) {
cb->beginPass(m_histRt[i].get(), QColor(0, 0, 0, 255),
QRhiDepthStencilClearValue(1.0f, 0), nullptr);
cb->endPass();
}
}
const int read = m_read;
const int write = 1 - m_read;
// Pass 1: the tunnel into history[write], sampling history[read].
cb->beginPass(m_histRt[write].get(), QColor(0, 0, 0, 255),
QRhiDepthStencilClearValue(1.0f, 0), u);
cb->setGraphicsPipeline(m_scenePipe.get());
cb->setViewport(QRhiViewport(0, 0, float(hist.width()), float(hist.height())));
cb->setShaderResources(m_srbScene[read].get());
const QRhiCommandBuffer::VertexInput tri(m_vbuf.get(), 0);
cb->setVertexInput(0, 1, &tri);
cb->draw(3);
cb->endPass();
// Pass 2: blit history[write] into the item target (the composite).
cb->beginPass(rt, QColor(0, 0, 0, 255), QRhiDepthStencilClearValue(1.0f, 0), nullptr);
cb->setGraphicsPipeline(m_blitPipe.get());
cb->setViewport(QRhiViewport(0, 0, float(ps.width()), float(ps.height())));
cb->setShaderResources(m_srbBlit[write].get());
cb->setVertexInput(0, 1, &tri);
cb->draw(3);
cb->endPass();
m_read = write;
}
private:
static QSize capped(const QSize &ps)
{
if (ps.width() <= MAX_W && ps.height() <= MAX_H)
return ps;
const double s = qMin(double(MAX_W) / ps.width(), double(MAX_H) / ps.height());
return QSize(qMax(1, int(ps.width() * s)), qMax(1, int(ps.height() * s)));
}
// (Re)build the ping-pong pair + its render targets + both SRB sets.
// Old resources go through deleteLater() — an in-flight frame may still
// reference them (QRhiResource::deleteLater is the documented deferral).
void rebuildHistory(const QSize &size)
{
QRhi *r = rhi();
for (int i = 0; i < 2; ++i) {
if (m_hist[i])
m_hist[i].release()->deleteLater();
if (m_histRt[i])
m_histRt[i].release()->deleteLater();
if (m_srbScene[i])
m_srbScene[i].release()->deleteLater();
if (m_srbBlit[i])
m_srbBlit[i].release()->deleteLater();
}
if (m_scenePipe)
m_scenePipe.release()->deleteLater();
m_scenePassDescriptor = nullptr;
m_histSize = size;
m_needClear = true;
m_read = 0;
for (int i = 0; i < 2; ++i) {
m_hist[i].reset(r->newTexture(QRhiTexture::RGBA8, size));
if (!m_hist[i]->create()) {
qWarning("[tunnelflow] history texture creation failed — the scene stays dark");
m_hist[0].reset();
m_hist[1].reset();
return;
}
// Vulkan/D3D/Metal REQUIRE a render pass descriptor before
// create(); without it QVkTextureRenderTarget warns and the
// later beginPass segfaults (2026-08-15 owner smoke: Tunnel
// Flow killed the app). The RT does NOT own the descriptor —
// free the previous one on rebuild.
if (m_histRpDesc[i]) {
delete m_histRpDesc[i];
m_histRpDesc[i] = nullptr;
}
m_histRt[i].reset(r->newTextureRenderTarget({ m_hist[i].get() }));
m_histRpDesc[i] = m_histRt[i]->newCompatibleRenderPassDescriptor();
m_histRt[i]->setRenderPassDescriptor(m_histRpDesc[i]);
if (!m_histRt[i]->create()) {
qWarning("[tunnelflow] history render target failed — the scene stays dark");
m_hist[0].reset();
m_hist[1].reset();
return;
}
}
// SRB sets: the scene pass binds ubuf(0) + the READ texture(1); the
// blit binds only the WRITE texture(1) (its shader declares nothing
// else). Both bilinear.
for (int i = 0; i < 2; ++i) {
m_srbScene[i].reset(r->newShaderResourceBindings());
m_srbScene[i]->setBindings({
QRhiShaderResourceBinding::uniformBuffer(
0,
QRhiShaderResourceBinding::VertexStage
| QRhiShaderResourceBinding::FragmentStage,
m_ubuf.get()),
QRhiShaderResourceBinding::sampledTexture(
1, QRhiShaderResourceBinding::FragmentStage,
m_hist[i].get(), m_sampler.get()),
});
m_srbBlit[i].reset(r->newShaderResourceBindings());
m_srbBlit[i]->setBindings({
QRhiShaderResourceBinding::sampledTexture(
1, QRhiShaderResourceBinding::FragmentStage,
m_hist[i].get(), m_sampler.get()),
});
if (!m_srbScene[i]->create() || !m_srbBlit[i]->create()) {
qWarning("[tunnelflow] shader resource bindings failed — the scene stays dark");
m_hist[0].reset();
m_hist[1].reset();
return;
}
}
}
void setVertexLayout(QRhiGraphicsPipeline *pipe)
{
QRhiVertexInputLayout inputLayout;
inputLayout.setBindings({ QRhiVertexInputBinding(quint32(2 * sizeof(float))) });
inputLayout.setAttributes({
{ 0, 0, QRhiVertexInputAttribute::Float2, 0 }, // pos
});
pipe->setVertexInputLayout(inputLayout);
}
void buildScenePipeline()
{
if (!m_scenePipe)
m_scenePipe.reset(rhi()->newGraphicsPipeline());
m_scenePipe->setTopology(QRhiGraphicsPipeline::Triangles);
m_scenePipe->setCullMode(QRhiGraphicsPipeline::None);
m_scenePipe->setDepthTest(false);
m_scenePipe->setDepthWrite(false);
// OPAQUE output (the FS writes alpha 1; the scene owns the
// background while active — the plasma header documents the same
// choice).
m_scenePipe->setShaderStages({
{ QRhiShaderStage::Vertex,
loadShader(QStringLiteral(
":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/rhi_triangle.vert.qsb")) },
{ QRhiShaderStage::Fragment,
loadShader(QStringLiteral(
":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/tunnel_flow.frag.qsb")) },
});
setVertexLayout(m_scenePipe.get());
m_scenePipe->setShaderResourceBindings(m_srbScene[0].get());
m_scenePipe->setRenderPassDescriptor(m_scenePassDescriptor);
if (!m_scenePipe->create()) {
qWarning("[tunnelflow] pipeline creation failed — the scene stays dark");
m_scenePipe.reset();
}
}
void buildBlitPipeline()
{
if (!m_blitPipe)
m_blitPipe.reset(rhi()->newGraphicsPipeline());
m_blitPipe->setTopology(QRhiGraphicsPipeline::Triangles);
m_blitPipe->setCullMode(QRhiGraphicsPipeline::None);
m_blitPipe->setDepthTest(false);
m_blitPipe->setDepthWrite(false);
m_blitPipe->setShaderStages({
{ QRhiShaderStage::Vertex,
loadShader(QStringLiteral(
":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/rhi_triangle.vert.qsb")) },
{ QRhiShaderStage::Fragment,
loadShader(QStringLiteral(
":/qt/qml/com/blitzfc/qbz/qml/assets/shaders/plasma_blit.frag.qsb")) },
});
setVertexLayout(m_blitPipe.get());
m_blitPipe->setShaderResourceBindings(m_srbBlit[0].get());
m_blitPipe->setRenderPassDescriptor(m_blitPassDescriptor);
if (!m_blitPipe->create()) {
qWarning("[tunnelflow] blit pipeline creation failed — the scene stays dark");
m_blitPipe.reset();
}
}
std::unique_ptr<QRhiBuffer> m_vbuf;
std::unique_ptr<QRhiBuffer> m_ubuf;
std::unique_ptr<QRhiSampler> m_sampler;
std::unique_ptr<QRhiTexture> m_hist[2];
std::unique_ptr<QRhiTextureRenderTarget> m_histRt[2];
// Render pass descriptors owned BY US (the RT borrows them) — see the
// rebuild loop. Raw pointers, freed on rebuild.
QRhiRenderPassDescriptor *m_histRpDesc[2] = { nullptr, nullptr };
std::unique_ptr<QRhiShaderResourceBindings> m_srbScene[2];
std::unique_ptr<QRhiShaderResourceBindings> m_srbBlit[2];
std::unique_ptr<QRhiGraphicsPipeline> m_scenePipe;
std::unique_ptr<QRhiGraphicsPipeline> m_blitPipe;
QRhiRenderPassDescriptor *m_scenePassDescriptor = nullptr;
QRhiRenderPassDescriptor *m_blitPassDescriptor = nullptr;
QSize m_histSize;
int m_read = 0;
bool m_needClear = true;
// synchronize() snapshots.
float m_time = 0.0f;
float m_phase = 0.0f;
float m_bass = 0.0f;
float m_mid = 0.0f;
float m_high = 0.0f;
float m_kick = 0.0f;
QVector4D m_palette0{ 1.0f, 0.416f, 0.416f, 1.0f };
QVector4D m_palette1{ 1.0f, 0.804f, 0.361f, 1.0f };
QVector4D m_palette2{ 0.408f, 0.863f, 0.667f, 1.0f };
QVector4D m_palette3{ 0.431f, 0.690f, 1.0f, 1.0f };
};
} // namespace
QQuickRhiItemRenderer *TunnelFlowItem::createRenderer()
{
return new TunnelFlowRenderer;
}
// QML type registration — the plasma_item.cpp idiom: the module is STATIC,
// so a startup function runs at QGuiApplication construction, BEFORE the
// engine loads the QML that names TunnelFlowItem.
static bool g_tunnelflowRegistered = false;
extern "C" void qbz_tunnelflow_register_qml_type()
{
if (g_tunnelflowRegistered)
return;
g_tunnelflowRegistered = true;
qmlRegisterType<TunnelFlowItem>("com.blitzfc.qbz", 1, 0, "TunnelFlowItem");
}
Q_COREAPP_STARTUP_FUNCTION(qbz_tunnelflow_register_qml_type)