feat: add blobs module

This commit is contained in:
2 * r + 2 * t 2026-03-25 12:04:39 +11:00
parent 501a14bd2a
commit f15b902c0e
14 changed files with 1361 additions and 1 deletions

View file

@ -0,0 +1,19 @@
qml_module(caelestia-blobs
URI Caelestia.Blobs
SOURCES
blobgroup.cpp
blobshape.cpp
blobrect.cpp
blobinvertedrect.cpp
blobmaterial.cpp
LIBRARIES
Qt::Quick
)
qt_add_shaders(caelestia-blobs "blob_shaders"
BATCHABLE OPTIMIZED NOHLSL NOMSL
PREFIX "/"
FILES
shaders/blob.frag
shaders/blob.vert
)

View file

@ -0,0 +1,75 @@
#include "blobgroup.hpp"
#include "blobinvertedrect.hpp"
#include "blobshape.hpp"
BlobGroup::BlobGroup(QObject* parent)
: QObject(parent) {}
BlobGroup::~BlobGroup() {
for (auto* shape : std::as_const(m_shapes))
shape->m_group = nullptr;
if (m_invertedRect)
static_cast<BlobShape*>(m_invertedRect)->m_group = nullptr;
}
void BlobGroup::setSmoothing(qreal s) {
if (qFuzzyCompare(m_smoothing, s))
return;
m_smoothing = s;
emit smoothingChanged();
markDirty();
}
void BlobGroup::setColor(const QColor& c) {
if (m_color == c)
return;
m_color = c;
emit colorChanged();
markDirty();
}
void BlobGroup::addShape(BlobShape* shape) {
if (!shape || m_shapes.contains(shape))
return;
m_shapes.append(shape);
markDirty();
}
void BlobGroup::removeShape(BlobShape* shape) {
m_shapes.removeOne(shape);
markDirty();
}
void BlobGroup::setInvertedRect(BlobInvertedRect* rect) {
if (m_invertedRect == rect)
return;
m_invertedRect = rect;
markDirty();
}
void BlobGroup::clearInvertedRect(BlobInvertedRect* rect) {
if (m_invertedRect != rect)
return;
m_invertedRect = nullptr;
markDirty();
}
void BlobGroup::markDirty() {
m_physicsUpdated = false;
for (auto* shape : std::as_const(m_shapes)) {
shape->polish();
shape->update();
}
if (m_invertedRect) {
static_cast<BlobShape*>(m_invertedRect)->polish();
static_cast<BlobShape*>(m_invertedRect)->update();
}
}
void BlobGroup::ensurePhysicsUpdated() {
if (m_physicsUpdated)
return;
m_physicsUpdated = true;
for (auto* shape : std::as_const(m_shapes))
shape->updatePhysics();
}

View file

@ -0,0 +1,53 @@
#pragma once
#include <qcolor.h>
#include <qlist.h>
#include <qobject.h>
#include <qqmlengine.h>
class BlobShape;
class BlobInvertedRect;
class BlobGroup : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(qreal smoothing READ smoothing WRITE setSmoothing NOTIFY
smoothingChanged)
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged)
public:
explicit BlobGroup(QObject* parent = nullptr);
~BlobGroup() override;
qreal smoothing() const { return m_smoothing; }
void setSmoothing(qreal s);
QColor color() const { return m_color; }
void setColor(const QColor& c);
void addShape(BlobShape* shape);
void removeShape(BlobShape* shape);
void setInvertedRect(BlobInvertedRect* rect);
void clearInvertedRect(BlobInvertedRect* rect);
const QList<BlobShape*>& shapes() const { return m_shapes; }
BlobInvertedRect* invertedRect() const { return m_invertedRect; }
void markDirty();
void ensurePhysicsUpdated();
signals:
void smoothingChanged();
void colorChanged();
private:
qreal m_smoothing = 32.0;
QColor m_color{ 0x44, 0x88, 0xff };
QList<BlobShape*> m_shapes;
BlobInvertedRect* m_invertedRect = nullptr;
bool m_physicsUpdated = false;
};

View file

@ -0,0 +1,56 @@
#include "blobinvertedrect.hpp"
#include "blobgroup.hpp"
BlobInvertedRect::BlobInvertedRect(QQuickItem* parent)
: BlobShape(parent) {}
BlobInvertedRect::~BlobInvertedRect() {
if (m_group)
m_group->clearInvertedRect(this);
}
void BlobInvertedRect::setBorderLeft(qreal v) {
if (qFuzzyCompare(m_borderLeft, v))
return;
m_borderLeft = v;
emit borderLeftChanged();
if (m_group)
m_group->markDirty();
}
void BlobInvertedRect::setBorderRight(qreal v) {
if (qFuzzyCompare(m_borderRight, v))
return;
m_borderRight = v;
emit borderRightChanged();
if (m_group)
m_group->markDirty();
}
void BlobInvertedRect::setBorderTop(qreal v) {
if (qFuzzyCompare(m_borderTop, v))
return;
m_borderTop = v;
emit borderTopChanged();
if (m_group)
m_group->markDirty();
}
void BlobInvertedRect::setBorderBottom(qreal v) {
if (qFuzzyCompare(m_borderBottom, v))
return;
m_borderBottom = v;
emit borderBottomChanged();
if (m_group)
m_group->markDirty();
}
void BlobInvertedRect::registerWithGroup() {
if (m_group)
m_group->setInvertedRect(this);
}
void BlobInvertedRect::unregisterFromGroup() {
if (m_group)
m_group->clearInvertedRect(this);
}

View file

@ -0,0 +1,56 @@
#pragma once
#include "blobshape.hpp"
#include <qqmlengine.h>
class BlobInvertedRect : public BlobShape {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(qreal borderLeft READ borderLeft WRITE setBorderLeft NOTIFY
borderLeftChanged)
Q_PROPERTY(qreal borderRight READ borderRight WRITE setBorderRight NOTIFY
borderRightChanged)
Q_PROPERTY(qreal borderTop READ borderTop WRITE setBorderTop NOTIFY
borderTopChanged)
Q_PROPERTY(qreal borderBottom READ borderBottom WRITE setBorderBottom NOTIFY
borderBottomChanged)
public:
explicit BlobInvertedRect(QQuickItem* parent = nullptr);
~BlobInvertedRect() override;
qreal borderLeft() const { return m_borderLeft; }
void setBorderLeft(qreal v);
qreal borderRight() const { return m_borderRight; }
void setBorderRight(qreal v);
qreal borderTop() const { return m_borderTop; }
void setBorderTop(qreal v);
qreal borderBottom() const { return m_borderBottom; }
void setBorderBottom(qreal v);
signals:
void borderLeftChanged();
void borderRightChanged();
void borderTopChanged();
void borderBottomChanged();
protected:
bool isInvertedRect() const override { return true; }
void registerWithGroup() override;
void unregisterFromGroup() override;
private:
qreal m_borderLeft = 0;
qreal m_borderRight = 0;
qreal m_borderTop = 0;
qreal m_borderBottom = 0;
};

View file

@ -0,0 +1,98 @@
#include "blobmaterial.hpp"
#include <cstring>
QSGMaterialType* BlobMaterial::type() const {
static QSGMaterialType s_type;
return &s_type;
}
QSGMaterialShader* BlobMaterial::createShader(
QSGRendererInterface::RenderMode) const {
return new BlobMaterialShader;
}
int BlobMaterial::compare(const QSGMaterial* other) const {
if (this < other)
return -1;
if (this > other)
return 1;
return 0;
}
BlobMaterialShader::BlobMaterialShader() {
setShaderFileName(VertexStage, QStringLiteral(":/shaders/blob.vert.qsb"));
setShaderFileName(FragmentStage, QStringLiteral(":/shaders/blob.frag.qsb"));
}
bool BlobMaterialShader::updateUniformData(
RenderState& state, QSGMaterial* newMaterial, QSGMaterial* oldMaterial) {
Q_UNUSED(oldMaterial);
auto* mat = static_cast<BlobMaterial*>(newMaterial);
QByteArray* buf = state.uniformData();
Q_ASSERT(buf->size() >= 1440);
if (state.isMatrixDirty()) {
const QMatrix4x4 m = state.combinedMatrix();
memcpy(buf->data(), m.constData(), 64);
}
if (state.isOpacityDirty()) {
const float opacity = state.opacity();
memcpy(buf->data() + 64, &opacity, 4);
}
// Padded rect (offset 68)
memcpy(buf->data() + 68, &mat->m_paddedX, 4);
memcpy(buf->data() + 72, &mat->m_paddedY, 4);
memcpy(buf->data() + 76, &mat->m_paddedW, 4);
memcpy(buf->data() + 80, &mat->m_paddedH, 4);
// Smooth factor (offset 84)
memcpy(buf->data() + 84, &mat->m_smoothFactor, 4);
// Rect count (offset 88)
memcpy(buf->data() + 88, &mat->m_rectCount, 4);
// My index (offset 92)
memcpy(buf->data() + 92, &mat->m_myIndex, 4);
// Color as vec4 (offset 96, 16 bytes)
const float color[4] = {
static_cast<float>(mat->m_color.redF()),
static_cast<float>(mat->m_color.greenF()),
static_cast<float>(mat->m_color.blueF()),
static_cast<float>(mat->m_color.alphaF()),
};
memcpy(buf->data() + 96, color, 16);
// Has inverted (offset 112)
memcpy(buf->data() + 112, &mat->m_hasInverted, 4);
// Inverted radius (offset 116)
memcpy(buf->data() + 116, &mat->m_invertedRadius, 4);
// Padding at 120-127 (skip)
// Inverted outer (offset 128, 16 bytes)
memcpy(buf->data() + 128, mat->m_invertedOuter, 16);
// Inverted inner (offset 144, 16 bytes)
memcpy(buf->data() + 144, mat->m_invertedInner, 16);
// Rect data (offset 160, each rect = 5 vec4s = 80 bytes)
const int count = qMin(mat->m_rectCount, 16);
for (int i = 0; i < count; ++i) {
const auto& r = mat->m_rects[i];
const int base = 160 + i * 80;
const float d0[4] = { r.cx, r.cy, r.hw, r.hh };
const float d1[4] = { r.radius, r.offsetX, r.offsetY, r.minEig };
const float d3[4] = { r.screenHalfX, r.screenHalfY, 0.0f, 0.0f };
memcpy(buf->data() + base, d0, 16);
memcpy(buf->data() + base + 16, d1, 16);
memcpy(buf->data() + base + 32, r.invDeform, 16);
memcpy(buf->data() + base + 48, d3, 16);
memcpy(buf->data() + base + 64, r.cornerFill, 16);
}
return true;
}

View file

@ -0,0 +1,47 @@
#pragma once
#include <qcolor.h>
#include <qsgmaterial.h>
#include <qsgmaterialshader.h>
struct BlobRectData {
float cx = 0, cy = 0, hw = 0, hh = 0;
float radius = 0;
float offsetX = 0, offsetY = 0;
float minEig = 1.0f;
// Inverse of 2x2 deformation matrix, column-major for GLSL
float invDeform[4] = { 1, 0, 0, 1 };
// Screen-space AABB half-extents of the deformed rect
float screenHalfX = 0, screenHalfY = 0;
// Pre-computed corner fill factors (tr, br, bl, tl)
float cornerFill[4] = { 1, 1, 1, 1 };
};
class BlobMaterial : public QSGMaterial {
public:
QSGMaterialType* type() const override;
QSGMaterialShader* createShader(
QSGRendererInterface::RenderMode) const override;
int compare(const QSGMaterial* other) const override;
float m_paddedX = 0;
float m_paddedY = 0;
float m_paddedW = 0;
float m_paddedH = 0;
float m_smoothFactor = 32.0f;
int m_rectCount = 0;
int m_myIndex = -2;
QColor m_color{ 0x44, 0x88, 0xff };
int m_hasInverted = 0;
float m_invertedRadius = 0;
float m_invertedOuter[4] = {};
float m_invertedInner[4] = {};
BlobRectData m_rects[16] = {};
};
class BlobMaterialShader : public QSGMaterialShader {
public:
BlobMaterialShader();
bool updateUniformData(RenderState& state, QSGMaterial* newMaterial,
QSGMaterial* oldMaterial) override;
};

View file

@ -0,0 +1,132 @@
#include "blobrect.hpp"
#include "blobgroup.hpp"
#include <algorithm>
#include <cmath>
BlobRect::BlobRect(QQuickItem* parent)
: BlobShape(parent) {}
BlobRect::~BlobRect() {
if (m_group)
m_group->removeShape(this);
}
void BlobRect::updatePolish() {
BlobShape::updatePolish();
if (m_physicsActive) {
QMetaObject::invokeMethod(
this,
[this]() {
if (m_physicsActive && m_group)
m_group->markDirty();
},
Qt::QueuedConnection);
}
}
void BlobRect::updatePhysics() {
const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0));
if (!m_hasPrevPos) {
m_prevScenePos = scenePos;
m_elapsed.start();
m_hasPrevPos = true;
return;
}
const float dt = static_cast<float>(m_elapsed.restart()) / 1000.0f;
if (dt > 0.1f || dt < 0.001f) {
m_prevScenePos = scenePos;
// Still check atRest on skipped frames to avoid getting stuck
if (m_physicsActive)
checkAtRest(0.0f);
return;
}
const float velX =
static_cast<float>(scenePos.x() - m_prevScenePos.x()) / dt;
const float velY =
static_cast<float>(scenePos.y() - m_prevScenePos.y()) / dt;
m_prevScenePos = scenePos;
const float speed = std::sqrt(velX * velX + velY * velY);
if (!m_physicsActive) {
if (speed < 5.0f)
return;
m_physicsActive = true;
}
// Compute target deformation matrix from velocity
// R(θ) * diag(stretch, compress) * R(θ)^T
const float kStretchFactor = static_cast<float>(m_deformScale);
constexpr float kMaxStretch = 0.35f;
float target00 = 1.0f;
float target01 = 0.0f;
float target11 = 1.0f;
if (speed > 5.0f) {
const float targetStretch =
1.0f + std::min(speed * kStretchFactor, kMaxStretch);
const float targetCompress = 1.0f / targetStretch;
const float cosA = velX / speed;
const float sinA = velY / speed;
const float cos2 = cosA * cosA;
const float sin2 = sinA * sinA;
const float cs = cosA * sinA;
target00 = targetStretch * cos2 + targetCompress * sin2;
target01 = (targetStretch - targetCompress) * cs;
target11 = targetStretch * sin2 + targetCompress * cos2;
}
// Underdamped spring on each matrix component
const float kStiffness = static_cast<float>(m_stiffness);
const float kDamping = static_cast<float>(m_damping);
const float accel00 =
-kStiffness * (m_dm00 - target00) - kDamping * m_dmVel00;
m_dmVel00 += accel00 * dt;
m_dm00 += m_dmVel00 * dt;
const float accel01 =
-kStiffness * (m_dm01 - target01) - kDamping * m_dmVel01;
m_dmVel01 += accel01 * dt;
m_dm01 += m_dmVel01 * dt;
const float accel11 =
-kStiffness * (m_dm11 - target11) - kDamping * m_dmVel11;
m_dmVel11 += accel11 * dt;
m_dm11 += m_dmVel11 * dt;
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
updateCenteredDeformMatrix();
checkAtRest(speed);
}
void BlobRect::checkAtRest(float speed) {
constexpr float kEpsilon = 0.002f;
const bool atRest =
std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon &&
std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon &&
std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon &&
speed < 5.0f;
if (atRest) {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_dmVel00 = 0.0f;
m_dmVel01 = 0.0f;
m_dmVel11 = 0.0f;
m_deformMatrix = QMatrix4x4(); // identity
updateCenteredDeformMatrix();
m_physicsActive = false;
}
}

View file

@ -0,0 +1,81 @@
#pragma once
#include "blobshape.hpp"
#include <qelapsedtimer.h>
#include <qqmlengine.h>
class BlobRect : public BlobShape {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(qreal stiffness READ stiffness WRITE setStiffness NOTIFY
stiffnessChanged)
Q_PROPERTY(
qreal damping READ damping WRITE setDamping NOTIFY dampingChanged)
Q_PROPERTY(qreal deformScale READ deformScale WRITE setDeformScale NOTIFY
deformScaleChanged)
public:
explicit BlobRect(QQuickItem* parent = nullptr);
~BlobRect() override;
qreal stiffness() const { return m_stiffness; }
void setStiffness(qreal s) {
if (!qFuzzyCompare(m_stiffness, s)) {
m_stiffness = s;
emit stiffnessChanged();
}
}
qreal damping() const { return m_damping; }
void setDamping(qreal d) {
if (!qFuzzyCompare(m_damping, d)) {
m_damping = d;
emit dampingChanged();
}
}
qreal deformScale() const { return m_deformScale; }
void setDeformScale(qreal s) {
if (!qFuzzyCompare(m_deformScale, s)) {
m_deformScale = s;
emit deformScaleChanged();
}
}
signals:
void stiffnessChanged();
void dampingChanged();
void deformScaleChanged();
protected:
void updatePolish() override;
void updatePhysics() override;
private:
void checkAtRest(float speed);
// Physics state
QPointF m_prevScenePos;
QElapsedTimer m_elapsed;
bool m_physicsActive = false;
bool m_hasPrevPos = false;
// Symmetric 2x2 deformation matrix components (3 independent: m00, m01,
// m11) Rest state is identity: m00=1, m01=0, m11=1
float m_dm00 = 1.0f;
float m_dm01 = 0.0f;
float m_dm11 = 1.0f;
// Spring velocities for each component
float m_dmVel00 = 0.0f;
float m_dmVel01 = 0.0f;
float m_dmVel11 = 0.0f;
qreal m_stiffness = 200.0;
qreal m_damping = 16.0;
qreal m_deformScale = 0.0005;
};

View file

@ -0,0 +1,365 @@
#include "blobshape.hpp"
#include "blobgroup.hpp"
#include "blobinvertedrect.hpp"
#include <qsggeometry.h>
#include <qsgnode.h>
#include <algorithm>
#include <cmath>
static float deformPadding(const QMatrix4x4& dm, float hw, float hh) {
// Bounding box of the deformed shape: |M * corners|
const float dm00 = dm(0, 0), dm01 = dm(0, 1);
const float dm10 = dm(1, 0), dm11 = dm(1, 1);
const float boundX = std::abs(dm00) * hw + std::abs(dm01) * hh;
const float boundY = std::abs(dm10) * hw + std::abs(dm11) * hh;
const float extraX = std::max(boundX - hw, 0.0f) + std::abs(dm(0, 3));
const float extraY = std::max(boundY - hh, 0.0f) + std::abs(dm(1, 3));
return std::max(extraX, extraY);
}
static float cpuSdBox(float px, float py, float cx, float cy, float hw,
float hh) {
const float dx = std::abs(px - cx) - hw;
const float dy = std::abs(py - cy) - hh;
const float mdx = std::max(dx, 0.0f);
const float mdy = std::max(dy, 0.0f);
return std::sqrt(mdx * mdx + mdy * mdy) + std::min(std::max(dx, dy), 0.0f);
}
static float cpuSmoothstep(float edge0, float edge1, float x) {
const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f);
return t * t * (3.0f - 2.0f * t);
}
BlobShape::BlobShape(QQuickItem* parent)
: QQuickItem(parent) {
setFlag(ItemHasContents);
}
void BlobShape::setGroup(BlobGroup* g) {
if (m_group == g)
return;
if (m_group && isComponentComplete())
unregisterFromGroup();
m_group = g;
if (m_group && isComponentComplete())
registerWithGroup();
emit groupChanged();
if (m_group)
m_group->markDirty();
}
void BlobShape::setRadius(qreal r) {
if (qFuzzyCompare(m_radius, r))
return;
m_radius = r;
emit radiusChanged();
if (m_group)
m_group->markDirty();
}
void BlobShape::componentComplete() {
QQuickItem::componentComplete();
if (m_group)
registerWithGroup();
}
void BlobShape::geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) {
QQuickItem::geometryChange(newGeometry, oldGeometry);
updateCenteredDeformMatrix();
if (m_group)
m_group->markDirty();
}
void BlobShape::updateCenteredDeformMatrix() {
const auto cx = static_cast<float>(width()) * 0.5f;
const auto cy = static_cast<float>(height()) * 0.5f;
QMatrix4x4 result;
result.translate(cx, cy);
result *= m_deformMatrix;
result.translate(-cx, -cy);
m_centeredDeformMatrix = result;
emit deformMatrixChanged();
}
void BlobShape::registerWithGroup() {
if (m_group)
m_group->addShape(this);
}
void BlobShape::unregisterFromGroup() {
if (m_group)
m_group->removeShape(this);
}
void BlobShape::updatePolish() {
if (!m_group)
return;
// Ensure all shapes have up-to-date physics (only once per frame)
m_group->ensurePhysicsUpdated();
// When inverted rect renders everything, skip spatial query for others
if (!isInvertedRect() && m_group->invertedRect())
return;
const QPointF scenePos = mapToScene(QPointF(0, 0));
const float pad = static_cast<float>(m_group->smoothing());
if (isInvertedRect()) {
m_cachedPaddedX = static_cast<float>(scenePos.x());
m_cachedPaddedY = static_cast<float>(scenePos.y());
m_cachedPaddedW = static_cast<float>(width());
m_cachedPaddedH = static_cast<float>(height());
m_localPaddedRect = QRectF(0, 0, width(), height());
} else {
const float hw = static_cast<float>(width()) * 0.5f;
const float hh = static_cast<float>(height()) * 0.5f;
const float totalPad = pad + deformPadding(m_deformMatrix, hw, hh);
m_cachedPaddedX = static_cast<float>(scenePos.x()) - totalPad;
m_cachedPaddedY = static_cast<float>(scenePos.y()) - totalPad;
m_cachedPaddedW = static_cast<float>(width()) + 2.0f * totalPad;
m_cachedPaddedH = static_cast<float>(height()) + 2.0f * totalPad;
m_localPaddedRect = QRectF(static_cast<double>(-totalPad),
static_cast<double>(-totalPad),
width() + 2.0 * static_cast<double>(totalPad),
height() + 2.0 * static_cast<double>(totalPad));
}
// Filter nearby normal rects
m_cachedRects.clear();
m_cachedMyIndex = -2;
const QRectF myPadded(static_cast<double>(m_cachedPaddedX),
static_cast<double>(m_cachedPaddedY),
static_cast<double>(m_cachedPaddedW),
static_cast<double>(m_cachedPaddedH));
for (BlobShape* other : m_group->shapes()) {
if (other->isInvertedRect())
continue;
const QPointF otherScene = other->mapToScene(QPointF(0, 0));
bool include = false;
if (isInvertedRect()) {
include = true;
} else {
const float otherHW = static_cast<float>(other->width()) * 0.5f;
const float otherHH = static_cast<float>(other->height()) * 0.5f;
const float otherPad =
pad + deformPadding(other->m_deformMatrix, otherHW, otherHH);
const QRectF otherPadded(
otherScene.x() - static_cast<double>(otherPad),
otherScene.y() - static_cast<double>(otherPad),
other->width() + 2.0 * static_cast<double>(otherPad),
other->height() + 2.0 * static_cast<double>(otherPad));
include = myPadded.intersects(otherPadded);
}
if (include) {
if (other == this)
m_cachedMyIndex = static_cast<int>(m_cachedRects.size());
const QMatrix4x4& dm = other->m_deformMatrix;
const float a = dm(0, 0), b = dm(1, 0);
const float c = dm(0, 1), d = dm(1, 1);
BlobRectData r;
r.cx = static_cast<float>(otherScene.x() + other->width() / 2.0);
r.cy = static_cast<float>(otherScene.y() + other->height() / 2.0);
r.hw = static_cast<float>(other->width() / 2.0);
r.hh = static_cast<float>(other->height() / 2.0);
r.radius = static_cast<float>(other->radius());
r.offsetX = dm(0, 3);
r.offsetY = dm(1, 3);
// Pre-compute inverse deformation matrix
const float det = a * d - c * b;
const float invDet =
std::abs(det) > 1e-6f ? 1.0f / det : 1.0f;
r.invDeform[0] = d * invDet;
r.invDeform[1] = -b * invDet;
r.invDeform[2] = -c * invDet;
r.invDeform[3] = a * invDet;
// Pre-compute minimum eigenvalue (avoids per-pixel sqrt)
const float halfTr = 0.5f * (a + d);
const float halfDiff = 0.5f * (a - d);
r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c);
// Pre-compute screen-space AABB half-extents
r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh;
r.screenHalfY = std::abs(b) * r.hw + std::abs(d) * r.hh;
m_cachedRects.append(r);
}
}
if (isInvertedRect())
m_cachedMyIndex = -1;
// Cache inverted rect data
m_cachedHasInverted = false;
m_cachedInvertedRadius = 0;
memset(m_cachedInvertedOuter, 0, sizeof(m_cachedInvertedOuter));
memset(m_cachedInvertedInner, 0, sizeof(m_cachedInvertedInner));
auto* inv = m_group->invertedRect();
if (inv) {
m_cachedHasInverted = true;
m_cachedInvertedRadius = static_cast<float>(inv->radius());
const QPointF invScene = inv->mapToScene(QPointF(0, 0));
const float outerCX =
static_cast<float>(invScene.x() + inv->width() / 2.0);
const float outerCY =
static_cast<float>(invScene.y() + inv->height() / 2.0);
const float outerHW = static_cast<float>(inv->width() / 2.0);
const float outerHH = static_cast<float>(inv->height() / 2.0);
const float innerCX =
outerCX +
static_cast<float>((inv->borderLeft() - inv->borderRight()) / 2.0);
const float innerCY =
outerCY +
static_cast<float>((inv->borderTop() - inv->borderBottom()) / 2.0);
const float innerHW =
outerHW -
static_cast<float>((inv->borderLeft() + inv->borderRight()) / 2.0);
const float innerHH =
outerHH -
static_cast<float>((inv->borderTop() + inv->borderBottom()) / 2.0);
m_cachedInvertedOuter[0] = outerCX;
m_cachedInvertedOuter[1] = outerCY;
m_cachedInvertedOuter[2] = outerHW;
m_cachedInvertedOuter[3] = outerHH;
m_cachedInvertedInner[0] = innerCX;
m_cachedInvertedInner[1] = innerCY;
m_cachedInvertedInner[2] = innerHW;
m_cachedInvertedInner[3] = innerHH;
}
// Pre-compute corner fill factors (moves O(N²) work from GPU to CPU)
const float smoothFactor = pad;
const auto rectCount = m_cachedRects.size();
for (qsizetype i = 0; i < rectCount; ++i) {
auto& ri = m_cachedRects[i];
float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f;
const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh;
const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh;
const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh;
const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh;
for (qsizetype j = 0; j < rectCount; ++j) {
if (j == i)
continue;
const auto& rj = m_cachedRects[j];
fTr = std::min(fTr, cpuSmoothstep(0.0f, smoothFactor,
cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh)));
fBr = std::min(fBr, cpuSmoothstep(0.0f, smoothFactor,
cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh)));
fBl = std::min(fBl, cpuSmoothstep(0.0f, smoothFactor,
cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh)));
fTl = std::min(fTl, cpuSmoothstep(0.0f, smoothFactor,
cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh)));
}
if (m_cachedHasInverted) {
const float icx = m_cachedInvertedInner[0];
const float icy = m_cachedInvertedInner[1];
const float ihw = m_cachedInvertedInner[2];
const float ihh = m_cachedInvertedInner[3];
fTr = std::min(fTr, cpuSmoothstep(0.0f, smoothFactor,
-cpuSdBox(cTrX, cTrY, icx, icy, ihw, ihh)));
fBr = std::min(fBr, cpuSmoothstep(0.0f, smoothFactor,
-cpuSdBox(cBrX, cBrY, icx, icy, ihw, ihh)));
fBl = std::min(fBl, cpuSmoothstep(0.0f, smoothFactor,
-cpuSdBox(cBlX, cBlY, icx, icy, ihw, ihh)));
fTl = std::min(fTl, cpuSmoothstep(0.0f, smoothFactor,
-cpuSdBox(cTlX, cTlY, icx, icy, ihw, ihh)));
}
ri.cornerFill[0] = fTr;
ri.cornerFill[1] = fBr;
ri.cornerFill[2] = fBl;
ri.cornerFill[3] = fTl;
}
}
QSGNode* BlobShape::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) {
if (!m_group) {
delete oldNode;
return nullptr;
}
// When an inverted rect exists, it renders everything in a single pass
if (!isInvertedRect() && m_group->invertedRect()) {
delete oldNode;
return nullptr;
}
auto* node = static_cast<QSGGeometryNode*>(oldNode);
if (!node) {
node = new QSGGeometryNode;
auto* geometry = new QSGGeometry(
QSGGeometry::defaultAttributes_TexturedPoint2D(), 4);
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
node->setGeometry(geometry);
node->setFlag(QSGNode::OwnsGeometry);
auto* material = new BlobMaterial;
material->setFlag(QSGMaterial::Blending);
node->setMaterial(material);
node->setFlag(QSGNode::OwnsMaterial);
}
// Update geometry
auto* geometry = node->geometry();
auto* v = geometry->vertexDataAsTexturedPoint2D();
const float x0 = static_cast<float>(m_localPaddedRect.x());
const float y0 = static_cast<float>(m_localPaddedRect.y());
const float x1 = x0 + static_cast<float>(m_localPaddedRect.width());
const float y1 = y0 + static_cast<float>(m_localPaddedRect.height());
v[0].set(x0, y0, 0.0f, 0.0f);
v[1].set(x1, y0, 1.0f, 0.0f);
v[2].set(x0, y1, 0.0f, 1.0f);
v[3].set(x1, y1, 1.0f, 1.0f);
node->markDirty(QSGNode::DirtyGeometry);
// Update material
auto* material = static_cast<BlobMaterial*>(node->material());
material->m_paddedX = m_cachedPaddedX;
material->m_paddedY = m_cachedPaddedY;
material->m_paddedW = m_cachedPaddedW;
material->m_paddedH = m_cachedPaddedH;
material->m_smoothFactor = static_cast<float>(m_group->smoothing());
material->m_myIndex = m_cachedMyIndex;
material->m_color = m_group->color();
material->m_hasInverted = m_cachedHasInverted ? 1 : 0;
material->m_invertedRadius = m_cachedInvertedRadius;
memcpy(material->m_invertedOuter, m_cachedInvertedOuter,
sizeof(m_cachedInvertedOuter));
memcpy(material->m_invertedInner, m_cachedInvertedInner,
sizeof(m_cachedInvertedInner));
const int count =
static_cast<int>(qMin(m_cachedRects.size(), qsizetype(16)));
material->m_rectCount = count;
for (int i = 0; i < count; ++i)
material->m_rects[i] = m_cachedRects[i];
node->markDirty(QSGNode::DirtyMaterial);
return node;
}

View file

@ -0,0 +1,71 @@
#pragma once
#include "blobmaterial.hpp"
#include <qmatrix4x4.h>
#include <qquickitem.h>
#include <qvector.h>
class BlobGroup;
class BlobShape : public QQuickItem {
Q_OBJECT
Q_PROPERTY(BlobGroup* group READ group WRITE setGroup NOTIFY groupChanged)
Q_PROPERTY(qreal radius READ radius WRITE setRadius NOTIFY radiusChanged)
Q_PROPERTY(
QMatrix4x4 deformMatrix READ deformMatrix NOTIFY deformMatrixChanged)
friend class BlobGroup;
public:
explicit BlobShape(QQuickItem* parent = nullptr);
~BlobShape() override = default;
BlobGroup* group() const { return m_group; }
void setGroup(BlobGroup* g);
qreal radius() const { return m_radius; }
void setRadius(qreal r);
QMatrix4x4 deformMatrix() const { return m_centeredDeformMatrix; }
signals:
void groupChanged();
void radiusChanged();
void deformMatrixChanged();
protected:
void componentComplete() override;
void geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) override;
void updatePolish() override;
QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) override;
virtual bool isInvertedRect() const { return false; }
virtual void updatePhysics() {}
virtual void registerWithGroup();
virtual void unregisterFromGroup();
void updateCenteredDeformMatrix();
BlobGroup* m_group = nullptr;
qreal m_radius = 0;
QMatrix4x4 m_deformMatrix; // identity by default
QMatrix4x4 m_centeredDeformMatrix;
// Cached data from updatePolish
float m_cachedPaddedX = 0;
float m_cachedPaddedY = 0;
float m_cachedPaddedW = 0;
float m_cachedPaddedH = 0;
QRectF m_localPaddedRect;
QVector<BlobRectData> m_cachedRects;
int m_cachedMyIndex = -2;
bool m_cachedHasInverted = false;
float m_cachedInvertedRadius = 0;
float m_cachedInvertedOuter[4] = {};
float m_cachedInvertedInner[4] = {};
};

View file

@ -0,0 +1,277 @@
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float paddedX;
float paddedY;
float paddedW;
float paddedH;
float smoothFactor;
int rectCount;
int myIndex;
vec4 color;
int hasInverted;
float invertedRadius;
vec4 invertedOuter;
vec4 invertedInner;
vec4 rectData[80];
};
float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float radius) {
vec2 d = abs(p - center) - halfSize + vec2(radius);
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - radius;
}
float sdRoundedBox4(vec2 p, vec2 center, vec2 halfSize, vec4 r) {
// r = (topRight, bottomRight, bottomLeft, topLeft)
p -= center;
r.xy = (p.x > 0.0) ? r.xy : r.wz;
r.x = (p.y > 0.0) ? r.y : r.x;
vec2 q = abs(p) - halfSize + r.x;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x;
}
float sdBox(vec2 p, vec2 center, vec2 halfSize) {
vec2 d = abs(p - center) - halfSize;
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0);
}
float smin(float a, float b, float k) {
// Cubic smooth min (C2 continuous — no curvature kinks at blend boundary)
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * h * k * (1.0/6.0);
}
float sminNoBulge(float a, float b, float k) {
// Cubic smooth min with reduced outward expansion when shapes overlap
float h = max(k - abs(a - b), 0.0) / k;
float blend = h * h * h * k * (1.0/6.0);
blend *= smoothstep(-k, 0.0, min(a, b));
return min(a, b) - blend;
}
float smax(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return max(a, b) + h * h * h * k * (1.0/6.0);
}
float smaxSharpA(float a, float b, float k) {
// smax variant that keeps a's boundary sharp (no inward rounding at a = 0).
// Used for the frame outer edge so it always fills to the edges.
float h = max(k - abs(a - b), 0.0) / k;
float blend = h * h * h * k * (1.0/6.0);
blend *= smoothstep(0.0, k * 0.5, -a);
return max(a, b) + blend;
}
void main() {
vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH);
float mergedSdf = 1e10;
int owner = -2;
float minDist = 1e10;
for (int i = 0; i < rectCount; i++) {
vec4 rect = rectData[i * 5]; // cx, cy, hw, hh
vec4 props = rectData[i * 5 + 1]; // radius, offsetX, offsetY, minEig
vec4 invDm = rectData[i * 5 + 2]; // inverse deform matrix
vec4 sh = rectData[i * 5 + 3]; // screenHalfX, screenHalfY, 0, 0
vec4 fills = rectData[i * 5 + 4]; // f_tr, f_br, f_bl, f_tl
// Offset center for asymmetric deformation
vec2 center = rect.xy + props.yz;
// Apply pre-computed inverse deformation to the evaluation point
mat2 invDeform = mat2(invDm.xy, invDm.zw);
vec2 transformedPixel = center + invDeform * (pixel - center);
// Use pre-computed corner fill factors
float br = props.x;
float minR = 2.0;
vec4 radii = max(br * fills, vec4(minR));
float d = sdRoundedBox4(transformedPixel, center, rect.zw, radii);
// Use pre-computed minimum eigenvalue for SDF correction
d *= max(props.w, 0.01);
// Scale SDF on the axis facing a nearby border to narrow the smin blend zone
// in that direction only, without reducing k (which would cause sharp edges).
if (hasInverted != 0) {
vec2 screenHalf = sh.xy;
float distY0 = (center.y + screenHalf.y) - (invertedInner.y - invertedInner.w);
float distY1 = (invertedInner.y + invertedInner.w) - (center.y - screenHalf.y);
float distX0 = (center.x + screenHalf.x) - (invertedInner.x - invertedInner.z);
float distX1 = (invertedInner.x + invertedInner.z) - (center.x - screenHalf.x);
// 0 = far from border, 1 = at border (max compression)
float yProx = 1.0 - min(
smoothstep(0.0, smoothFactor, distY0),
smoothstep(0.0, smoothFactor, distY1)
);
float xProx = 1.0 - min(
smoothstep(0.0, smoothFactor, distX0),
smoothstep(0.0, smoothFactor, distX1)
);
// Smooth axis weights: gradient-based at corners, face-based inside.
vec2 q = abs(pixel - center) - screenHalf;
vec2 qp = max(q, vec2(0.0));
float cornerLen = length(qp);
// Gradient direction in corner region (smooth 90-degree rotation)
float gradX = qp.x / max(cornerLen, 0.001);
float gradY = qp.y / max(cornerLen, 0.001);
// Smooth face weights for inside/edge (no hard branch)
float faceY = smoothstep(-4.0, 4.0, q.y - q.x);
float faceX = 1.0 - faceY;
// Blend: gradient in corner region, face-based inside
float t = smoothstep(0.0, 2.0, cornerLen);
float xWeight = mix(faceX, gradX, t);
float yWeight = mix(faceY, gradY, t);
float boost = 3.0;
float scale = 1.0 + (xProx * xWeight + yProx * yWeight) * boost;
d *= scale;
}
// Rect-to-rect edge sink: indent this rect's edge where another
// rect is slightly past it, fading once past threshold.
if (rectCount > 1) {
vec2 iSh = sh.xy;
float sinkT = smoothFactor * 0.75;
float sinkOff = smoothFactor * (1.0 / 6.0);
float rectSinkVal = 0.0;
for (int j = 0; j < rectCount; j++) {
if (j == i) continue;
vec4 jR = rectData[j * 5];
vec4 jP = rectData[j * 5 + 1];
vec2 jSh = rectData[j * 5 + 3].xy;
vec2 jC = jR.xy + jP.yz;
// Penetration of j past i's edges (positive = past)
float pT = (jC.y + jSh.y) - (center.y - iSh.y) - sinkOff;
float pB = (center.y + iSh.y) - (jC.y - jSh.y) - sinkOff;
float pL = (jC.x + jSh.x) - (center.x - iSh.x) - sinkOff;
float pR = (center.x + iSh.x) - (jC.x - jSh.x) - sinkOff;
// Smooth bump: rises then falls, zero outside [0, sinkT]
float aT = smoothstep(0.0, sinkT * 0.4, pT) * (1.0 - smoothstep(sinkT * 0.5, sinkT, pT));
float aB = smoothstep(0.0, sinkT * 0.4, pB) * (1.0 - smoothstep(sinkT * 0.5, sinkT, pB));
float aL = smoothstep(0.0, sinkT * 0.4, pL) * (1.0 - smoothstep(sinkT * 0.5, sinkT, pL));
float aR = smoothstep(0.0, sinkT * 0.4, pR) * (1.0 - smoothstep(sinkT * 0.5, sinkT, pR));
// Lateral falloff from rect j's extent
float hLat = max(abs(pixel.x - jC.x) - jSh.x, 0.0);
float vLat = max(abs(pixel.y - jC.y) - jSh.y, 0.0);
float latF = smoothFactor * 2.0;
// Perpendicular zone (near rect i's edge only)
float zT = 1.0 - smoothstep(center.y - iSh.y, center.y - iSh.y + smoothFactor, pixel.y);
float zB = smoothstep(center.y + iSh.y - smoothFactor, center.y + iSh.y, pixel.y);
float zL = 1.0 - smoothstep(center.x - iSh.x, center.x - iSh.x + smoothFactor, pixel.x);
float zR = smoothstep(center.x + iSh.x - smoothFactor, center.x + iSh.x, pixel.x);
float s = max(
max(aT * smoothstep(latF, 0.0, hLat) * zT,
aB * smoothstep(latF, 0.0, hLat) * zB),
max(aL * smoothstep(latF, 0.0, vLat) * zL,
aR * smoothstep(latF, 0.0, vLat) * zR)
);
rectSinkVal = max(rectSinkVal, s);
}
d += rectSinkVal * smoothFactor * 0.25;
}
mergedSdf = sminNoBulge(mergedSdf, d, smoothFactor);
if (d < minDist) {
minDist = d;
owner = i;
}
}
if (hasInverted != 0) {
float dOuter = sdBox(pixel, invertedOuter.xy, invertedOuter.zw) - 1.0;
float dInner = sdRoundedBox(pixel, invertedInner.xy, invertedInner.zw, invertedRadius);
// Border sinks: track the opposite rect edge, clamped to border thickness
float innerTop = invertedInner.y - invertedInner.w;
float innerBot = invertedInner.y + invertedInner.w;
float innerLeft = invertedInner.x - invertedInner.z;
float innerRight = invertedInner.x + invertedInner.z;
float outerTop = invertedOuter.y - invertedOuter.w;
float outerBot = invertedOuter.y + invertedOuter.w;
float outerLeft = invertedOuter.x - invertedOuter.z;
float outerRight = invertedOuter.x + invertedOuter.z;
float sinkValue = 0.0;
for (int i = 0; i < rectCount; i++) {
vec4 rect = rectData[i * 5];
vec4 sinkProps = rectData[i * 5 + 1];
vec2 sinkSh = rectData[i * 5 + 3].xy;
// Screen-space center (with offset) and pre-computed AABB half-extents
vec2 ctr = rect.xy + sinkProps.yz;
// Delay sink to absorb smin blend depth (cubic smin max = k/6)
float preOff = smoothFactor * (1.0/6.0);
// Top border: track rect's BOTTOM edge, only within border thickness
float topPen = clamp(innerTop - (ctr.y + sinkSh.y) - preOff, 0.0, innerTop - outerTop);
// Bottom border: track rect's TOP edge
float botPen = clamp((ctr.y - sinkSh.y) - innerBot - preOff, 0.0, outerBot - innerBot);
// Left border: track rect's RIGHT edge
float leftPen = clamp(innerLeft - (ctr.x + sinkSh.x) - preOff, 0.0, innerLeft - outerLeft);
// Right border: track rect's LEFT edge
float rightPen = clamp((ctr.x - sinkSh.x) - innerRight - preOff, 0.0, outerRight - innerRight);
// Lateral distance from pixel to rect's extent along each edge
float hLat = max(abs(pixel.x - ctr.x) - sinkSh.x, 0.0);
float vLat = max(abs(pixel.y - ctr.y) - sinkSh.y, 0.0);
// Perpendicular proximity: full strength in border, fade inside inner area
float topZone = 1.0 - smoothstep(innerTop, innerTop + smoothFactor, pixel.y);
float botZone = smoothstep(innerBot - smoothFactor, innerBot, pixel.y);
float leftZone = 1.0 - smoothstep(innerLeft, innerLeft + smoothFactor, pixel.x);
float rightZone = smoothstep(innerRight - smoothFactor, innerRight, pixel.x);
float s = smoothFactor * 2.0;
float sink = max(
max(topPen * smoothstep(s, 0.0, hLat) * topZone,
botPen * smoothstep(s, 0.0, hLat) * botZone),
max(leftPen * smoothstep(s, 0.0, vLat) * leftZone,
rightPen * smoothstep(s, 0.0, vLat) * rightZone)
);
sinkValue = max(sinkValue, sink);
}
dInner -= sinkValue;
float dFrame = smaxSharpA(dOuter, -dInner, smoothFactor);
mergedSdf = smin(mergedSdf, dFrame, smoothFactor);
if (dFrame < minDist) {
owner = -1;
}
}
// myIndex == -1: inverted rect renders everything (frame + blobs)
// myIndex >= 0: individual rect renders only its owned pixels
if (myIndex >= 0 && owner != myIndex)
discard;
float fw = fwidth(mergedSdf);
float alpha = 1.0 - smoothstep(-fw, fw, mergedSdf);
fragColor = vec4(color.rgb * alpha, alpha) * qt_Opacity;
}

View file

@ -0,0 +1,29 @@
#version 440
layout(location = 0) in vec4 qt_VertexPosition;
layout(location = 1) in vec2 qt_VertexTexCoord;
layout(location = 0) out vec2 qt_TexCoord0;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float paddedX;
float paddedY;
float paddedW;
float paddedH;
float smoothFactor;
int rectCount;
int myIndex;
vec4 color;
int hasInverted;
float invertedRadius;
vec4 invertedOuter;
vec4 invertedInner;
vec4 rectData[80];
};
void main() {
gl_Position = qt_Matrix * qt_VertexPosition;
qt_TexCoord0 = qt_VertexTexCoord;
}

View file

@ -1,4 +1,4 @@
find_package(Qt6 REQUIRED COMPONENTS Core Qml Gui Quick Concurrent Sql Network DBus)
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus)
find_package(PkgConfig REQUIRED)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
@ -60,3 +60,4 @@ qml_module(caelestia
add_subdirectory(Internal)
add_subdirectory(Models)
add_subdirectory(Services)
add_subdirectory(Blobs)