diff --git a/CMakeLists.txt b/CMakeLists.txt index 164b97b..0a832bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -304,6 +304,7 @@ qt_add_qml_module(hyperbin qml/Main.qml qml/BinGlyph.qml qml/OozeVisual.qml + qml/OozeEyes.qml qml/Permissions.qml qml/Splash.qml qml/HyperbinLockup.qml @@ -359,6 +360,14 @@ qt_add_resources(hyperbin "hyperbin_icons" # that silently falls back to the system UI face on every machine # but the one it was designed on is not a brand. resources/Inter-SemiBold.ttf + # The eyeballs in the goo. Converted from a glTF with balsam; + # see docs/effects.md. The source model's cornea is a second, + # transparent mesh — left out on purpose, because a transparent + # PBR shell costs a full extra pass to produce a highlight that + # is sub-pixel at the size this is drawn. + resources/meshes/eye.mesh + resources/maps/eye_albedo.jpg + resources/maps/eye_normal.png ) qt_add_resources(hyperbin "hyperbin_shaders3d" PREFIX "/shaders3d" @@ -366,6 +375,8 @@ qt_add_resources(hyperbin "hyperbin_shaders3d" FILES shaders/ooze3d.vert shaders/ooze3d.frag + shaders/eye3d.vert + shaders/eye3d.frag ) # Headless swarm check. No GUI, so it runs on CI and under the debugger diff --git a/qml/Main.qml b/qml/Main.qml index 50199f6..06bb659 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -72,7 +72,14 @@ Window { // margins are proportional to the icon, so this is the // same geometry the Dock produces, just larger. binRect = Qt.rect(90, 40, 300, 300); - fullness = 0.85; + // HYPERBIN_PREVIEW_FULL pins the slider, so anything that + // scales with fullness — the ooze's level, how many eyes + // surface — can be shot at a chosen value instead of + // being dragged to it by hand. + fullness = Number(Qt.application.arguments.indexOf("--full") >= 0 + ? Qt.application.arguments[ + Qt.application.arguments.indexOf("--full") + 1] + : 0.85); frameIntervalMs = 16; } } diff --git a/qml/OozeEyes.qml b/qml/OozeEyes.qml new file mode 100644 index 0000000..3c32e63 --- /dev/null +++ b/qml/OozeEyes.qml @@ -0,0 +1,208 @@ +import QtQuick +import QtQuick3D + +// Eyeballs set into the gel. +// +// They do not travel. Where each one sits, how big it is and which way +// the gel faces under it all come from OozeEffect, which owns the body's +// profile — see its eyeSpheres/eyeNormals. What is left here is the part +// that needs the CAMERA, which the scene owns and the effect does not: +// aiming them. +// +// The pupils flick between fixations the way real eyes do: a saccade is +// close to instantaneous, so these snap rather than glide. Easing them +// would look like something searching; snapping looks like something +// watching. +Node { + id: eyes + + /// The OozeEffect. Supplies the clock and where the eyes are. + required property var effect + /// The camera's tilt in degrees. Every gaze starts from here. + required property real camTilt + + /// The gel's own colour and how it absorbs and scatters, handed + /// straight down from the material that draws the body. + /// + /// Passed rather than restated. The lids are goo closing over the + /// ball and are shaded with the same maths, so if these two ever + /// disagreed the lid would be a slightly different substance from the + /// body it is supposed to be part of — the kind of difference nobody + /// spots as a wrong number, only as "the eyes look stuck on". + required property color gooTint + required property real gooAbsorption + required property real gooScatter + + /// How far the gel's own surface pulls a gaze off the camera. + /// + /// At 0 every eye stares down the lens wherever it sits, which is + /// what this looked like first and why the ones out near the + /// silhouette read as decals: an eye buried in something has an + /// orbit, and an orbit points where the surface points. At 1 they + /// would face straight out of the body and the ones on the sides + /// would be looking away entirely. + /// + /// It costs the eyes on the front almost nothing whatever it is set + /// to — the normal there is already very nearly the camera — so this + /// is really a setting for the flanks alone. At a third, one out on + /// the side turned by only about thirty degrees, which still read as + /// an eye pasted on the silhouette staring past its own socket. Over + /// half turns it about fifty, which is a ball sitting in the goo and + /// looking out of it, and it keeps watching you everywhere it can + /// plausibly be said to be facing you at all. + property real gazeBias: 0.55 + + readonly property var spheres: effect ? effect.eyeSpheres : [] + readonly property var normals: effect ? effect.eyeNormals : [] + readonly property real time: effect ? effect.time : 0 + + /// Toward the camera. It is orthographic, so this is the same + /// direction everywhere in the scene and there is no per-eye look-at + /// to compute — only the lean away from it that the gel asks for. + readonly property vector3d camDir: Qt.vector3d( + 0, Math.sin(camTilt * Math.PI / 180), Math.cos(camTilt * Math.PI / 180)) + + /// The camera's own up, in the scene. The lids close along it, so + /// they sweep the face of each ball that is actually visible rather + /// than some direction that only happens to suit the eyes on the + /// body's wall. It is camDir turned a quarter turn about x. + readonly property vector3d camUp: Qt.vector3d( + 0, Math.cos(camTilt * Math.PI / 180), -Math.sin(camTilt * Math.PI / 180)) + + // A stable per-eye value in [0,1). Same index, same number, every + // frame — so an eye keeps its saccade instead of being re-rolled + // whenever anything re-evaluates. + function rnd(a, b) { + const s = Math.sin(a * 127.1 + b * 311.7) * 43758.5453; + return s - Math.floor(s); + } + + Repeater3D { + // CONSTANT, and the list's length only decides how many are + // shown. Driving the count from the level rebuilds every delegate + // on the frame it changes, and this scene has been here before: + // the bubbles did exactly that and cost four times the whole + // effect before they were removed. + model: eyes.effect ? eyes.effect.maxEyes : 0 + + delegate: Node { + id: eye + required property int index + + readonly property bool out: index < eyes.spheres.length + readonly property var sphere: out ? eyes.spheres[index] : null + readonly property var normal: out ? eyes.normals[index] : null + readonly property real size: sphere ? sphere.w : 1 + /// 1 wide open, 0 shut. Rides in the normal's spare slot. + readonly property real open: normal ? normal.w : 1 + /// How far this eye's lid axis is canted off the horizontal, + /// in radians. Constant per eye — it is anatomy, not motion — + /// so it is hashed here rather than sent every frame. + readonly property real lidRoll: (eyes.rnd(index, 37) - 0.5) * 0.9 + + visible: out + position: sphere ? Qt.vector3d(sphere.x, sphere.y, sphere.z) + : Qt.vector3d(0, 0, 0) + + // --- the gaze frame ----------------------------------------- + // Somewhere between the lens and the way the gel faces here. + readonly property vector3d gaze: normal + ? eyes.camDir.times(1 - eyes.gazeBias) + .plus(Qt.vector3d(normal.x, normal.y, normal.z) + .times(eyes.gazeBias)) + .normalized() + : eyes.camDir + + // Yaw then pitch, as two nested nodes rather than one node's + // eulerRotation. Which order Qt composes a pair of Euler + // angles in is a thing to look up and get wrong; nesting says + // it outright, and the pair is exactly the decomposition of a + // direction into (turn, then lift): + // + // Ry(yaw) * Rx(pitch) * +z = (sin y cos p, -sin p, cos y cos p) + Node { + eulerRotation.y: Math.atan2(eye.gaze.x, eye.gaze.z) * 180 / Math.PI + + Node { + eulerRotation.x: -Math.asin(Math.max(-1, Math.min(1, eye.gaze.y))) + * 180 / Math.PI + + Node { + // The saccade. Floor(time) picks a fixation, and + // the fixation is hashed into an offset, so the + // eye holds still and then jumps — no + // interpolation anywhere. + readonly property int fix: + Math.floor(eyes.time * 1.6 + eye.index * 3.7) + readonly property bool blank: eyes.rnd(fix, eye.index) > 0.72 + + // A fixation that lands on the camera, roughly one + // in four, is what sells the rest: an eye that is + // always darting never appears to have noticed + // you. + eulerRotation.x: blank ? 0 : (eyes.rnd(fix, eye.index + 11) - 0.5) * 26 + eulerRotation.y: blank ? 0 : (eyes.rnd(fix, eye.index + 23) - 0.5) * 34 + + Model { + source: "qrc:/icons/meshes/eye.mesh" + // The iris is on the model's -z; +z is the + // back of the eyeball. Measured by rendering + // the mesh from six directions rather than + // read off the asset. + eulerRotation.y: 180 + scale: Qt.vector3d(eye.size, eye.size, eye.size) + + // One material per eye, and it has to be: + // `openness` is a uniform, and nine eyes + // blinking on nine different clocks need nine + // values of it. They share one shader and one + // pipeline — this is nine uniform buffers, + // not nine programs — and the maps are + // declared once above and merely referenced, + // so it is also one copy of each texture. + materials: CustomMaterial { + shadingMode: CustomMaterial.Shaded + cullMode: CustomMaterial.BackFaceCulling + property real openness: eye.open + property real gooTime: eyes.time + property vector3d camUp: eyes.camUp + property vector3d gazeW: eye.gaze + property real lidRoll: eye.lidRoll + // Keeps the ragged rim of one eye's lid + // from being the same rag as its + // neighbour's. + property real gooSeed: eye.index * 7.31 + property color gooTint: eyes.gooTint + property real gooAbsorption: eyes.gooAbsorption + property real gooScatter: eyes.gooScatter + property TextureInput eyeAlbedo: TextureInput { + texture: albedoMap + } + property TextureInput eyeNormalMap: TextureInput { + texture: normalMap + } + vertexShader: "qrc:/shaders3d/eye3d.vert" + fragmentShader: "qrc:/shaders3d/eye3d.frag" + } + } + } + } + } + } + } + + // Declared once, referenced by all nine materials, so this is one + // upload of each rather than nine. + Texture { + id: albedoMap + source: "qrc:/icons/maps/eye_albedo.jpg" + generateMipmaps: true + mipFilter: Texture.Linear + } + Texture { + id: normalMap + source: "qrc:/icons/maps/eye_normal.png" + generateMipmaps: true + mipFilter: Texture.Linear + } +} diff --git a/qml/OozeVisual.qml b/qml/OozeVisual.qml index f4af089..66167ce 100644 --- a/qml/OozeVisual.qml +++ b/qml/OozeVisual.qml @@ -28,6 +28,14 @@ Item { readonly property real binW: effect ? effect.binSize.width : 40 readonly property real binH: effect ? effect.binSize.height : 40 + // Where the eyes are, so the gel can climb them. (x, y, z, radius), + // and only the ones currently out are in the list — see + // OozeEffect::eyeSpheres. + readonly property var eyeList: effect ? effect.eyeSpheres : [] + function eyeSphere(i) { + return i < eyeList.length ? eyeList[i] : Qt.vector4d(0, 0, 0, 0) + } + View3D { anchors.fill: parent camera: cam @@ -88,6 +96,19 @@ Item { - root.width / 2 : 0 y: root.effect ? -(root.effect.binRect.y + root.effect.binRect.height / 2 - root.height / 2) : 0 + // The eyes go in BEFORE the gel in the scene graph so the gel's + // transparent pass composites over them — an eye drawn after it + // sits on top of the body instead of inside it. + OozeEyes { + effect: root.effect + camTilt: cam.tilt + // The lids are the same substance as the body, so they read + // the body's numbers rather than carrying their own. + gooTint: sludgeMaterial.tint + gooAbsorption: sludgeMaterial.absorption + gooScatter: sludgeMaterial.scatter + } + Model { id: sludge geometry: OozeGeometry { @@ -147,6 +168,28 @@ Item { property real dispersion: 0.35 property color tint: "#6fbf3a" + // The eyes, one uniform each. + // + // Written out rather than looped, because a CustomMaterial has no + // array uniform and GLSL cannot index a uniform that is not an + // array. A slot nobody is using has a radius of zero, which the + // shaders read as "not there" — so the list is as long as + // OozeEffect::kMaxEyes and usually only part full. + property vector4d eye0: root.eyeSphere(0) + property vector4d eye1: root.eyeSphere(1) + property vector4d eye2: root.eyeSphere(2) + property vector4d eye3: root.eyeSphere(3) + property vector4d eye4: root.eyeSphere(4) + property vector4d eye5: root.eyeSphere(5) + property vector4d eye6: root.eyeSphere(6) + property vector4d eye7: root.eyeSphere(7) + property vector4d eye8: root.eyeSphere(8) + property vector4d eye9: root.eyeSphere(9) + property vector4d eye10: root.eyeSphere(10) + property vector4d eye11: root.eyeSphere(11) + property vector4d eye12: root.eyeSphere(12) + property vector4d eye13: root.eyeSphere(13) + vertexShader: "qrc:/shaders3d/ooze3d.vert" fragmentShader: "qrc:/shaders3d/ooze3d.frag" } diff --git a/resources/maps/eye_albedo.jpg b/resources/maps/eye_albedo.jpg new file mode 100644 index 0000000..61bd7f0 Binary files /dev/null and b/resources/maps/eye_albedo.jpg differ diff --git a/resources/maps/eye_normal.png b/resources/maps/eye_normal.png new file mode 100644 index 0000000..c041a1c Binary files /dev/null and b/resources/maps/eye_normal.png differ diff --git a/resources/meshes/eye.mesh b/resources/meshes/eye.mesh new file mode 100644 index 0000000..99038c2 Binary files /dev/null and b/resources/meshes/eye.mesh differ diff --git a/shaders/eye3d.frag b/shaders/eye3d.frag new file mode 100644 index 0000000..47f4904 --- /dev/null +++ b/shaders/eye3d.frag @@ -0,0 +1,185 @@ +VARYING vec3 vDirW; + +// sRGB in, linear out. Same reason as the gel's: a shaded CustomMaterial +// hands BASE_COLOR to Qt's own lighting and the scene tonemaps on the way +// out, so a colour literal or an 8-bit map fed in raw is gamma-encoded +// twice. +vec3 toLinear(vec3 c) +{ + return pow(max(c, vec3(0.0)), vec3(2.2)); +} + +float h31(vec3 p) +{ + return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); +} +float vnoise3(vec3 p) +{ + vec3 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = mix(h31(i), h31(i + vec3(1, 0, 0)), f.x); + float b = mix(h31(i + vec3(0, 1, 0)), h31(i + vec3(1, 1, 0)), f.x); + float c = mix(h31(i + vec3(0, 0, 1)), h31(i + vec3(1, 0, 1)), f.x); + float d = mix(h31(i + vec3(0, 1, 1)), h31(i + vec3(1, 1, 1)), f.x); + return mix(mix(a, b, f.y), mix(c, d, f.y), f.z); +} + +// The lids, drawn ON the eyeball. +// +// The obvious build is a second sphere around this one, split and peeled +// back, wearing the gel's own material. It cannot work here, and the +// reason is worth writing down because it is not about the modelling: +// +// * the gel is drawn AFTER the eyes and is transparent, so it is depth +// tested and does not depth write. Wherever the ball is nearer than +// the gel, the ball wins outright — so nothing in the gel's fragment +// shader can ever cover an eye, however opaque it makes itself. +// +// * the gel CAN cover one by moving, which is what the socket does. +// But the body is 64 segments round and at these sizes one segment +// is as wide as a whole eye, so the gel can only close over one as a +// blob. A blink is a fast, crisp edge; that read as mush. +// +// The ball has neither problem. Its mesh is dense, and it is the nearest +// surface, so it owns these pixels by construction. What makes it read as +// goo rather than as an eyelid is that it is shaded through the SAME +// absorption and scattering the gel uses, from the same tint: where the +// cover is thin the eyeball still shows through it, and it goes opaque +// further in. The lid is not a colour, it is depth of goo. +void MAIN() +{ + vec4 albedo = texture(eyeAlbedo, UV0); + vec3 eyeCol = toLinear(albedo.rgb); + vec3 n0 = normalize(NORMAL); + + // --- how far under the goo ----------------------------------------- + // Latitude up the face of the ball you can actually SEE: the camera's + // up, squared off against this eye's own gaze. Three frames were + // tried and the first two each failed somewhere specific. + // + // * the model's own frame rolls with every saccade, so the lids + // swam about as the eye darted. + // + // * the scene's up is right for an eye on the body's wall and + // exactly wrong for one in the puddle. That surface is nearly + // horizontal, so a puddle eye's pole faces the camera, and a lid + // closing from "the top of the ball" closed over the pupil first + // and left the white all round it bare. + // + // * the camera's up, raw, is better and still not right. A puddle + // eye looks up out of the spill by about forty degrees, so the + // centre of its visible face is not at zero in that frame — the + // lid reached the pupil while the top of the ball was still + // clear, which is the same failure a third of the way along. + // + // Taking the component perpendicular to the gaze puts the middle of + // the visible face at zero for every eye, wall or puddle, so one pair + // of numbers closes all of them the same way. It cannot go degenerate + // here: no gaze turns more than about a third of a right angle off + // the lens, and the camera's up is square to that. + vec3 up0 = normalize(camUp - gazeW * dot(camUp, gazeW)); + // ...and then CANTED, by its own angle per eye. + // + // Lids square to the horizon are the giveaway. Every real set sits on + // a tilted axis — the outer corner higher than the inner — and a + // whole bin of them closing along exactly the same line reads as nine + // copies of one thing rather than nine of a kind. The roll turns the + // whole aperture, both lids together, which is what an eye does; two + // lids canted separately would be a pair of jaws. + vec3 right0 = cross(up0, gazeW); + vec3 up = up0 * cos(lidRoll) + right0 * sin(lidRoll); + vec3 right = right0 * cos(lidRoll) - up0 * sin(lidRoll); + // The face of the ball, as flat coordinates. Both axes are square to + // the gaze, and the camera is orthographic, so these ARE the pixel + // offsets across the visible disc: -1 to 1 either way. + float x = dot(vDirW, right); + float y = dot(vDirW, up); + + // A ragged edge, but only slightly. A clean arc reads as a moulded + // eyelid and the point of this is that gel has closed over the ball, + // which does not have an edge like that. At 0.15 though it stopped + // reading as a lid at all: the noise is worth a sixth of the ball's + // radius, enough to retract one end of the lid past the other and + // leave what looked like a bite taken out of the top. Drifting + // slowly, so the rim creeps. + float wob = (vnoise3(vDirW * 4.6 + vec3(gooSeed, gooTime * 0.18, gooSeed)) + - 0.5) * 0.07; + + // Open is NOT bare. A lid you only ever see on the way down is not a + // lid, it is a wipe: the eye has to be sitting in an aperture the + // whole time so that closing it is the same shape continuing to move. + // These sat out past the poles at first and took a sliver off — the + // ball read as a bead that occasionally got swallowed. + // + // y IS the projected height up the visible disc, so 0.52 takes the + // top quarter of it: an upper lid resting over the top of the iris, + // which is where a real one sits. The lower takes a little less, and + // the upper travels further on the way down, as a real pair does. + // Shut, the two OVERLAP, and by more than looks necessary. + // + // They used to cross by a tenth, which was ample while the edges were + // straight. Bowing them toward each other eats that margin from the + // middle: two curves that meet at the corners are furthest apart at + // the centre, and with the wobble at full throw the overlap there + // went negative — leaving a bright open slit across the eye on the + // one frame it was supposed to be shut. Worked out rather than + // eyeballed, because it is a single frame in a blink and would + // never have been caught by looking. + float topEdge = mix(-0.40, 0.52, openness) + wob; + float botEdge = mix(0.06, -0.58, openness) - wob; + + // The edges CURVE, and they have to. + // + // Straight ones were the whole of what looked wrong. A lid defined by + // "everything past this latitude" is a plane cut through a sphere, + // and a plane cut is a half-dome cap — so the eye wore two skullcaps, + // top and bottom, with a straight band of aperture between them. + // + // Tilting the two cuts apart does not fix it either, and that is + // worth knowing before trying it: both boundary circles contain the + // view direction, so under an orthographic camera each projects to a + // straight LINE. Two lines cross once. Tilting them just seals one + // side of the eye and opens the other into a wedge. + // + // What makes an eye an eye is that both edges bow toward each other + // and meet at BOTH ends, which is a curve, not a cut. A quadratic is + // enough: at this coefficient the two meet just inside the ball's + // silhouette with about forty-five degrees between them, so the + // aperture is a proper pointed almond and the corners land where the + // goo is already lapping over the rim anyway. + // + // The upper bows harder than the lower. They do on a face too. + float overTop = (y + 0.62 * x * x) - topEdge; + float overBot = (botEdge + 0.50 * x * x) - y; + float over = max(overTop, overBot); + // A short ramp. The goo has to fade in rather than step — a hard + // alpha edge on a ball this size crawls with aliasing as the lid + // moves — but over a fifth of the radius the lid had no line to it at + // all and read as a stain. + float cover = smoothstep(-0.02, 0.10, over); + + // --- the goo over it ------------------------------------------------ + // Beer-Lambert against the eyeball itself, exactly as the gel runs it + // against the bin. At cover 0 the transmittance is 1 and this is the + // eyeball untouched, so the two sides meet with no seam to hide. + vec3 tintL = toLinear(gooTint.rgb); + vec3 sigma = (vec3(1.0) - tintL) * gooAbsorption * 3.0; + vec3 trans = exp(-sigma * (1.45 * cover)); + BASE_COLOR = vec4(eyeCol * trans + tintL * gooScatter * (vec3(1.0) - trans), + 1.0); + + // The eyeball's own bump, and it stops where the goo starts: what is + // under a covering of gel does not show its texture through it. + vec3 nm = texture(eyeNormalMap, UV0).rgb * 2.0 - 1.0; + vec3 mapped = normalize(TANGENT * nm.x + BINORMAL * nm.y + n0 * nm.z); + NORMAL = normalize(mix(mapped, n0, cover)); + + // Wet where it is bare and gel where it is not. The ball is polished + // and the gel is not quite, so this is a real difference and it is + // one more thing telling the two apart at a size where the shapes + // cannot. + ROUGHNESS = mix(0.18, 0.24, cover); + SPECULAR_AMOUNT = mix(0.90, 0.40, cover); + METALNESS = 0.0; + FRESNEL_POWER = 2.5; +} diff --git a/shaders/eye3d.vert b/shaders/eye3d.vert new file mode 100644 index 0000000..923eb29 --- /dev/null +++ b/shaders/eye3d.vert @@ -0,0 +1,26 @@ +// The eyeball, and the goo that closes over it. +// +// All this stage does is hand the fragment shader the direction of this +// surface point in the SCENE's frame rather than the model's. Everything +// about the lid is decided there, and every one of those decisions has to +// be independent of which way the eye is currently pointing: +// +// * the lid must not turn with the eye. Eyelids stay put while the eye +// rotates under them, and these are goo — goo hangs downward whatever +// the ball beneath it is looking at. Measured in model space the lid +// line rolled with every saccade. +// +// * the ragged edge must not swim. Noise sampled on the model's own +// coordinates is carried around by the ball's rotation, so the +// wobble crawled along the rim each time the eye darted. Sampled on +// the scene direction it is fixed in the world, which is where the +// goo is. +// +// One vector answers both. For a sphere the outward direction IS the +// surface normal, so this is also exactly the axis the latitude wants. +VARYING vec3 vDirW; + +void MAIN() +{ + vDirW = normalize(mat3(MODEL_MATRIX) * VERTEX); +} diff --git a/shaders/ooze3d.frag b/shaders/ooze3d.frag index c105ef0..6b174e1 100644 --- a/shaders/ooze3d.frag +++ b/shaders/ooze3d.frag @@ -1,5 +1,6 @@ VARYING vec2 vScreen; VARYING vec3 vNormalW; +VARYING vec3 vPosO; VARYING float vThick; VARYING float vHeight; VARYING float vLump; @@ -21,6 +22,47 @@ vec3 toLinear(vec3 c) return pow(max(c, vec3(0.0)), vec3(2.2)); } +// The meniscus where the gel meets an eye. +// +// This is the same division of labour the lumps use in the vertex shader, +// for the same reason. There, the coarse band moves vertices and the fine +// band only tilts the normal, because detail below the vertex spacing +// cannot be in the geometry at all. Here the eye is the thing below the +// spacing: the body is 64 segments round, and at the size these are, one +// segment is as wide as a whole eye. The broad swell fits in the mesh and +// is done there; the crease around the rim of the ball does not fit in +// the mesh at any amplitude, and pushing harder on the vertices only ever +// made the swell wider. +// +// A RING, and that is the whole point of it. Something pressed into a +// thick liquid is not surrounded by a mound — the liquid is held down by +// the ball itself and piles up at its edge, so the peak belongs just +// outside the silhouette. A shape that peaked at the centre would have to +// be tall enough to show at the rim, and anything that tall closed over +// the pupil. +// +// Returns the ring's height, 0..1, and accumulates the slope along the +// surface so the light has something to catch. +float eyeCollar(vec3 p, vec3 nrm, vec4 e, inout vec3 slope) +{ + if (e.w <= 0.0) + return 0.0; + vec3 rel = p - e.xyz; + float u = length(rel) / e.w; + float k = (u - 1.20) / 1.15; // peak just outside the ball, gone by 2.35 + float w = 1.0 - k * k; + if (w <= 0.0) + return 0.0; + // Away from the eye, along the surface. Near the ball's silhouette + // this is very nearly the view direction, so it has to be projected + // back onto the tangent plane or the tilt goes into the surface. + vec3 t = rel - nrm * dot(rel, nrm); + float tl = length(t); + if (tl > 1e-5) + slope += (t / tl) * (-2.0 * k / 1.15); + return w; +} + void MAIN() { // Where we are on the bin, 0..1. @@ -38,13 +80,47 @@ void MAIN() // outline was what made the gel take the bin's form, slots and all, // instead of being a body the bin sits inside. vec3 n = normalize(vNormalW); + + // Deepest wins rather than stacking, as everywhere else here: two + // eyes close together would otherwise raise a rim twice the size of + // anything either could make alone. No check for whether the eye is + // on the far side of the body — the ring dies out at a couple of eye + // radii and the far side is most of a bin away, so the distance has + // already answered it. + vec3 slope = vec3(0.0); + float collar = eyeCollar(vPosO, n, eye0, slope); + collar = max(collar, eyeCollar(vPosO, n, eye1, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye2, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye3, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye4, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye5, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye6, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye7, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye8, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye9, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye10, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye11, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye12, slope)); + collar = max(collar, eyeCollar(vPosO, n, eye13, slope)); + // Overstated, and deliberately — the same constant fudge the vertex + // shader applies to the lumps and for the same reason. The body is + // smooth and the environment is a soft studio probe, so a + // geometrically honest tilt moves this shading by almost nothing. An + // honest 0.55 went in first and the ring was, measured against a flat + // red debug fill, landing exactly where it should and shading like it + // was not there at all. + n = normalize(n - slope * 1.70); + // The lumps thicken and thin the body as well as bending its surface. // Shape alone was nearly invisible: a smooth gel under a soft studio // probe shades almost identically whichever way its normal points, so // the animation was running and could not be seen. Varying how much // gel the light has to cross is what makes it read as something // alive, because it changes the COLOUR and not just the highlight. - float thick = clamp(vThick * (1.0 + vLump * 0.55), 0.0, 1.0); + // The collar is gel piled up, so there is more of it to see through + // here — which is what turns the ring from a highlight into something + // with depth, for the same reason the lumps feed this at all. + float thick = clamp(vThick * (1.0 + vLump * 0.55) + collar * 0.55, 0.0, 1.0); // --- refraction --------------------------------------------------- // Two parts, and the second is what makes it read as something @@ -179,8 +255,15 @@ void MAIN() // Low roughness with a clearcoat is what makes it wet rather than // rubbery; the environment does the rest. // Not a mirror: at 0.07 with a clearcoat this read as chromed metal. - ROUGHNESS = 0.24; METALNESS = 0.0; FRESNEL_POWER = 2.5; - SPECULAR_AMOUNT = 0.40 * (1.0 - lidHole); + // Wettest at the meniscus. The crest of the collar is the thinnest, + // most sharply curved gel anywhere on the body, and on a real one + // that is where the light runs in a line. It is also the cue that + // does the most work here: the ring's SHAPE is nearly invisible under + // a probe this soft, but a bright edge at the ball's rim reads + // immediately as the two things touching. + float wet = smoothstep(0.45, 1.0, collar); + ROUGHNESS = 0.24 - 0.09 * wet; + SPECULAR_AMOUNT = (0.40 + 0.50 * wet) * (1.0 - lidHole); } diff --git a/shaders/ooze3d.vert b/shaders/ooze3d.vert index 38dd4de..ee85129 100644 --- a/shaders/ooze3d.vert +++ b/shaders/ooze3d.vert @@ -8,6 +8,11 @@ // wants is where the pixel actually LANDS, in the bin's own pixels. VARYING vec2 vScreen; VARYING vec3 vNormalW; +// Where this pixel is on the body, in the bin's own units and in the same +// space the eyes are given in — so the fragment shader can measure its +// distance to one. Object space rather than world: the scene translates +// the whole thing onto the bin, and the eyes are not translated with it. +VARYING vec3 vPosO; VARYING float vThick; VARYING float vHeight; VARYING float vLump; @@ -109,6 +114,57 @@ float blisterAt(vec3 q) } return s; } +// The gel climbing an eye. +// +// Something set into a thick liquid does not leave the surface flat +// around it: the liquid rides up its sides and holds it. Without this the +// eyes were beads resting ON the body, which is the same failure the +// ridges were added to fix one level up — a shape carrying no evidence of +// anything ever having touched it. +// +// A quadratic dome rather than another noise octave, for the same reason +// the blisters are one: what is wanted is a few distinct ROUND things in +// places that are known, and noise of any spectrum gives an even scatter +// of bumps instead. +// +// It peaks at the eye's own centre, which is the one part of it nobody +// ever sees — the eyeball is drawn there, in front, and hides it. What +// reads is the ring around the outside. That is also why the reach is +// well over the eye's own radius: a dome that stopped at the ball would +// be entirely behind it. +float eyeBulge(vec3 q, vec4 e) +{ + // e.xyz is the centre, e.w the radius. Zero means the slot is empty. + if (e.w <= 0.0) + return 0.0; + float d = length(q - e.xyz) / (e.w * 2.4); + // Scaled by the EYE, not by the bin. A rise measured against the bin + // is the same for all of them, and the eyes vary in size by nearly + // two to one — so one number either drowned the small ones or barely + // touched the large ones, and there was no value in between. + return max(0.0, 1.0 - d * d) * e.w * 0.85; +} +float eyesAt(vec3 q) +{ + // Deepest wins rather than stacking, as with the blisters: two eyes + // that happened to sit close would otherwise raise one lump twice the + // size of anything either could make alone. + float s = eyeBulge(q, eye0); + s = max(s, eyeBulge(q, eye1)); + s = max(s, eyeBulge(q, eye2)); + s = max(s, eyeBulge(q, eye3)); + s = max(s, eyeBulge(q, eye4)); + s = max(s, eyeBulge(q, eye5)); + s = max(s, eyeBulge(q, eye6)); + s = max(s, eyeBulge(q, eye7)); + s = max(s, eyeBulge(q, eye8)); + s = max(s, eyeBulge(q, eye9)); + s = max(s, eyeBulge(q, eye10)); + s = max(s, eyeBulge(q, eye11)); + s = max(s, eyeBulge(q, eye12)); + s = max(s, eyeBulge(q, eye13)); + return s; +} // Where the puddle has gathered. // // A ring of even thickness is the one thing a spill never is: it runs to @@ -139,7 +195,10 @@ float shadeAt(vec3 q, float lumpScale, float poolW, out float coarse, + fine * binWidth * 0.030 + waveAt(q) * binWidth * 0.015 * (1.0 - poolW) + gatherAt(q) * binWidth * 0.042 * poolW - + blisterAt(q) * binWidth * 0.055 * poolW; + + blisterAt(q) * binWidth * 0.055 * poolW + // Not windowed by the pool: the eyes are up on the body, which + // is where poolW has already gone to zero. + + eyesAt(q); } void MAIN() { @@ -185,7 +244,8 @@ void MAIN() float d0 = lumpRaw * binWidth * 0.030 * lumpScale + waveAt(VERTEX) * binWidth * 0.015 * (1.0 - poolW) + gatherAt(VERTEX) * binWidth * 0.042 * poolW - + blisterAt(VERTEX) * binWidth * 0.055 * poolW; + + blisterAt(VERTEX) * binWidth * 0.055 * poolW + + eyesAt(VERTEX); // ...and a breath. One slow cycle over about ten seconds, swelling // the whole body a hair — a thing that is perfectly still reads as a @@ -250,5 +310,6 @@ void MAIN() vScreen = (mv * vec4(pos, 1.0)).xy - (mv * vec4(0.0, 0.0, 0.0, 1.0)).xy; vNormalW = normalize(NORMAL_MATRIX * n); + vPosO = pos; POSITION = MODELVIEWPROJECTION_MATRIX * vec4(pos, 1.0); } diff --git a/src/core/OozeSim.h b/src/core/OozeSim.h index 6363294..ba0abbe 100644 --- a/src/core/OozeSim.h +++ b/src/core/OozeSim.h @@ -32,6 +32,31 @@ class OozeSim /// How far down the bin the coating has crept, 0..1. Eased, so a bin /// emptied in one go does not snap clean. float level() const { return m_level; } + + /// How full the bin READS, 0 to 1, over the level's own easing. + /// + /// Not the same question as level(), and the difference has caught + /// something already. level() is how deep the gel is, and it is + /// floored at minShare the instant the bin is dirty at all — so it + /// only ever moves across the top third of its range, because a + /// shallower smear than that cannot be seen through a translucent + /// bin. Anything that wants to scale with how full the bin is, rather + /// than with how deep the goo is, has to have that floor taken back + /// off; used raw, level() makes a quarter-full bin look nearly the + /// same as a full one. + /// + /// Taken off the eased level rather than from fullness() directly, so + /// whatever reads this arrives with the goo rather than snapping the + /// moment the bin changes. + float fill() const + { + const float lo = params.maxLevel * params.minShare; + const float span = params.maxLevel - lo; + if (span <= 0.0f) + return m_level > 0.0f ? 1.0f : 0.0f; + const float f = (m_level - lo) / span; + return f < 0.0f ? 0.0f : (f > 1.0f ? 1.0f : f); + } /// Seconds since the coating first appeared. Drives the drip cycles. float time() const { return m_time; } diff --git a/src/effects/OozeEffect.cpp b/src/effects/OozeEffect.cpp index d614683..c780cc9 100644 --- a/src/effects/OozeEffect.cpp +++ b/src/effects/OozeEffect.cpp @@ -1,6 +1,7 @@ #include "OozeEffect.h" #include +#include #include namespace hyperbin { @@ -101,6 +102,7 @@ void OozeEffect::setSurface(const QVector &coverage, int w, int h) void OozeEffect::step(float dt) { m_sim.step(dt); + updateEyes(); emit frameChanged(); const bool empty = isEmpty(); @@ -117,6 +119,430 @@ QMargins OozeEffect::margins(qreal iconSize) const x, OozeSim::marginBottom(iconSize)); } +OozeEffect::EyeSeat OozeEffect::sweepSeat(float r, float y, float drdp, + float dydp, float angle) +{ + // Every surface here is one ring radius and one height, both moving + // with a single parameter and swept round the axis. Its normal falls + // straight out of the two tangents: + // + // P(a, p) = (r sin a, y, r D cos a) + // dP/da x dP/dp = r * (D y' sin a, -r' D, y' cos a) + // + // The leading r only scales it, so it drops out in the normalise. + // Note the D on the x term and NOT on the z: a squashed circle's + // normal leans toward the flattened axis, which is why an eye on the + // front of the body faces the camera more squarely than one on the + // side faces sideways. + // + // The body walks this with p = height, so y' is 1 and r' is the + // profile's slope. The puddle walks it with p along the shoulder, + // where both move — and there r' is large and negative, which is what + // turns the normal to face nearly straight up. + // angle 0 puts the eye on +z, which is the side the camera is on. + EyeSeat s; + s.pos = QVector3D(r * std::sin(angle), y, + r * std::cos(angle) * OozeShape::kDepth); + s.nrm = QVector3D(OozeShape::kDepth * dydp * std::sin(angle), + -drdp * OozeShape::kDepth, + dydp * std::cos(angle)) + .normalized(); + return s; +} + +OozeEffect::EyeSeat OozeEffect::bodySeat(float t, float angle) const +{ + // Between the pool's crest and the gel's surface. + const float lo = m_shape.poolCrest(); + const float hi = m_shape.surfaceY(m_contentLine, m_sim.level()); + const float y = lo + (hi - lo) * qBound(0.0f, t, 1.0f); + + const float e = std::max(0.5f, float(m_binSize.height()) * 0.02f); + const float dR = (m_shape.radiusAt(y + e) - m_shape.radiusAt(y - e)) / (2.0f * e); + return sweepSeat(m_shape.radiusAt(y), y, dR, 1.0f, angle); +} + +OozeEffect::EyeSeat OozeEffect::poolSeat(float u, float angle) const +{ + // The puddle's top, which is the shoulder OozeGeometry sweeps: out at + // the rim it is the widest part of the spill, and it climbs and draws + // back in on a smoothstep until it meets the foot of the body. Walked + // here with the same two lines the mesh uses, because an eye that sat + // on a different curve from the one the goo is made of would float + // above the spill or disappear under it. + const float poolR = m_shape.poolRadius(m_sim.level()); + const float crest = m_shape.poolCrest(); + const float topY = m_shape.poolTop(); + const auto ring = [&](float p) { + p = qBound(0.0f, p, 1.0f); + const float y = crest + (topY - crest) * p; + const float e = p * p * (3.0f - 2.0f * p); + return QVector3D(poolR + (m_shape.radiusAt(y) - poolR) * e, y, 0.0f); + }; + constexpr float kDu = 0.02f; + const QVector3D at = ring(u); + const QVector3D lo = ring(u - kDu), hi = ring(u + kDu); + return sweepSeat(at.x(), at.y(), + (hi.x() - lo.x()) / (2.0f * kDu), + (hi.y() - lo.y()) / (2.0f * kDu), angle); +} + +float OozeEffect::slotY(const EyeSlot &s) const +{ + // Both surfaces are measured up from the pool's crest — bodySeat and + // poolSeat each start there — so one line answers for either, and the + // only difference is how far up they reach. + const float crest = m_shape.poolCrest(); + const float topOf = s.pool ? m_shape.poolTop() + : m_shape.surfaceY(m_contentLine, m_sim.level()); + return crest + (topOf - crest) * qBound(0.0f, s.param, 1.0f); +} + +void OozeEffect::spreadEyes(EyeSlot *slot, int n) const +{ + if (n < 2) + return; + + const float binW = float(m_binSize.width()); + // Not merely "not intersecting". Two balls exactly touching still + // read as one lumpy thing at this size, and the goo needs somewhere + // to close around each of them — the collar reaches well over an eye's + // own radius, so leaving them a hair apart puts two meniscus rings on + // top of each other and neither shows. + const float margin = binW * 0.045f; + const float crest = m_shape.poolCrest(); + + // Measured in the surface's OWN units, not in the parameters. + // + // Angle and height are not comparable: a tenth of a radian is a fifth + // of the body's width, and a tenth of the height parameter is a + // fifteenth of its height. Pushing in parameter space moves eyes by + // wildly different amounts depending on which way they happen to be + // crowded, which mostly moved them sideways and never resolved a + // vertical pile. Arc length and height are both in bin pixels and can + // simply be compared. + // What each eye's own numbers are, worked out ONCE per pass and not + // once per pair. They only depend on one eye each, and there are n of + // them against n^2/2 pairs — computed in the inner loop, the profile + // lookups alone cost more than everything else in this effect put + // together, and measurably so: it put half again on the whole app's + // processor time. + const float bodySpan = m_shape.surfaceY(m_contentLine, m_sim.level()) - crest; + const float poolSpan = m_shape.poolTop() - crest; + float ey[kMaxEyes], ering[kMaxEyes], espan[kMaxEyes]; + + for (int pass = 0; pass < 8; ++pass) { + for (int i = 0; i < n; ++i) { + espan[i] = std::max(slot[i].pool ? poolSpan : bodySpan, 1.0f); + ey[i] = crest + espan[i] * qBound(0.0f, slot[i].param, 1.0f); + ering[i] = m_shape.radiusAt(ey[i]); + } + bool moved = false; + for (int a = 0; a < n; ++a) { + for (int b = a + 1; b < n; ++b) { + const float ya = ey[a], yb = ey[b]; + const float rA = ering[a], rB = ering[b]; + // Arc length, discounted by how edge-on it is. + // + // Two eyes out on the flank are properly apart in SPACE + // and still look stacked: the surface there is turned + // almost side-on, so a step round it barely moves on + // screen — about a quarter as far as the same step across + // the front. Spreading them by true arc alone left the + // silhouette looking like a pile of eyes that measured + // clean. Counting arc for less where the body turns away + // buys those pairs a wider berth, and the floor keeps it + // from running to infinity at the exact edge. + const float face = 0.5f * (std::cos(slot[a].angle) + + std::cos(slot[b].angle)); + const float ring = 0.5f * (rA + rB) + * (0.40f + 0.60f * std::abs(face)); + const float dx = ring * (slot[b].angle - slot[a].angle); + const float dy = yb - ya; + const float dist = std::sqrt(dx * dx + dy * dy); + const float need = slot[a].radius + slot[b].radius + margin; + if (dist >= need) + continue; + + // Straight on top of each other, which the sequences can + // do exactly: pick an axis rather than dividing by zero. + float ux = 1.0f, uy = 0.0f; + if (dist > 1e-3f) { + ux = dx / dist; + uy = dy / dist; + } + const float push = (need - dist) * 0.5f; + const float over = push * ux / std::max(ring, 1.0f); + slot[a].angle -= over; + slot[b].angle += over; + slot[a].param -= push * uy / espan[a]; + slot[b].param += push * uy / espan[b]; + moved = true; + } + } + + // Back inside their bounds after every pass, not once at the end. + // Clamping only at the end lets a pair settle in a place the + // clamp then undoes, putting them back on top of each other with + // no pass left to notice. + for (int i = 0; i < n; ++i) { + // A little past the arc they were dealt, so a crowded front + // has somewhere to give — but not so far round that an eye + // ends up behind the bin. + slot[i].angle = qBound(-2.30f, slot[i].angle, 2.30f); + if (slot[i].pool) { + slot[i].param = qBound(0.10f, slot[i].param, 0.80f); + } else { + // The same ceiling the placement applies, recomputed: + // spreading moves eyes round the body, and the ceiling + // depends on where round it they are. + const float back = 0.5f * (1.0f - std::cos(slot[i].angle)); + slot[i].param = qBound(0.14f, slot[i].param, 0.90f - 0.34f * back); + } + } + if (!moved) + break; + } +} + +/// The same hash the scene used when it placed these itself, so an eye +/// keeps the identity it already had: same index, same size, same spot. +static float eyeHash(float a, float b) +{ + const float s = std::sin(a * 127.1f + b * 311.7f) * 43758.5453f; + return s - std::floor(s); +} + +void OozeEffect::updateEyes() +{ + m_eyeSpheres.clear(); + m_eyeNormals.clear(); + + // How many are out, and it is NOT proportional to the level. + // + // Straight proportion spread the difference evenly and so made + // neither end of the range feel like anything: a quarter-full bin + // still had a scatter of them, and a full one was only a few more. + // Squaring holds the low end nearly clear — a bin with a little in + // it gets one or two, which reads as something starting — and banks + // the whole increase into the top, where the point is that it should + // look infested. + // + // fill(), NOT level(). The two are different questions and using the + // wrong one here looked exactly like the code not working: level() is + // how DEEP the goo is and is floored at seventy per cent the moment + // the bin is dirty, so squaring it separated a quarter-full bin from + // a full one by a factor of two rather than of sixteen, and all four + // test renders came out with much the same crowd. + // + // With a floor of one, though. Squared alone rounds to nothing below + // about a third full, and a bin with goo in it and no eyes at all is + // not a quieter version of this effect — it is a different one. The + // first eye arrives with the first of the sludge; the rest are what + // the fill buys. + const float fill = m_sim.fill(); + const int want = int(std::lround(fill * fill * kMaxEyes)); + const int n = m_sim.isEmpty() ? 0 : qBound(1, want, kMaxEyes); + const float t = m_sim.time(); + const float binW = float(m_binSize.width()); + + // Placed first, spread second, seated third. The spreading has to see + // all of them at once, so it cannot happen inside the loop that makes + // them. + EyeSlot slot[kMaxEyes] {}; + + for (int i = 0; i < n; ++i) { + const float fi = float(i); + const float seedA = eyeHash(fi, 1.0f); + const float seedB = eyeHash(fi, 2.0f); + const float seedC = eyeHash(fi, 3.0f); + + // RADIUS, not diameter. The mesh is a unit sphere, so a scale of s + // spans 2s — sized as a diameter, the first pass came out at twice + // the intent and the eyes sat on the gel rather than in it. + const float radius = binW * (0.036f + 0.028f * seedA); + + // WHERE an eye is does not change, and that is the point. + // + // These used to walk round the body — an angle with the clock in + // it — and a ball of gel with eyes revolving through it does not + // read as anything alive. Nothing suspended in something this + // thick travels; it sits where it set. What is left is a wobble + // small enough to be the gel shifting under it rather than the eye + // going anywhere: about a degree of turn and a hair of rise. + // + // Where they go is a LOW-DISCREPANCY set, not a random draw. + // + // Random was fine while they travelled: one that started out of + // sight came round eventually. Now that they stay put, a draw + // that happens to put six of the nine on the back of the bin puts + // them there for good, which is exactly what the first run of + // this looked like. + // + // The two coordinates get DIFFERENT constructions, and the reason + // is that this is nine points, not nine hundred. + // + // An R2 sequence went in first, on the reasoning that a + // two-dimensional low-discrepancy set is the right tool for a + // two-dimensional placement. It is — asymptotically. At nine + // points its stride comes back to within a fiftieth of where it + // started after only four steps, so while the 2D spread is fine + // its ONE-dimensional projections clump into pairs and triples. + // An angle that clumps is three eyes stacked in a vertical line + // down the front of the bin, which is what it drew. + // + // A golden-angle stride has no such return and lays nine points + // out very nearly evenly; the radical inverse in base two does + // the same for the height and does not track it. Both are chosen + // for THIS count rather than in the limit. + // Both sequences are started so that index 0 lands somewhere + // WORTH looking at, because the order is not arbitrary: the count + // rises with the bin, so the first indices are the only ones a + // barely-dirty bin ever shows. Left at their natural starts, eye + // zero came out on the back-left flank at the very bottom of the + // range — a bin with a little in it grew exactly one eye and it + // was behind the goo. The half-turn puts it dead centre front, + // and stepping the height sequence on by one puts it at mid + // height rather than down in the puddle. + const float ua = std::fmod(0.5f + fi * 0.3819660113f, 1.0f); + float ub = 0.0f; + for (int bits = i + 1, place = 1; bits; bits >>= 1, ++place) + ub += (bits & 1) ? std::ldexp(1.0f, -place) : 0.0f; + + // Squeezed toward the camera, and LINEARLY. + // + // Spread over the whole circle, two thirds of them sit on a side + // of the body the camera never sees. Bending the angle by a power + // was the first attempt at that and it traded one fault for + // another: a power pulls hardest near zero, so it stacked eyes on + // the centre line while barely moving the ones round the back. A + // plain scale keeps whatever spacing it was handed and only + // narrows the arc — nine eyes evenly spread across the front and + // both flanks, the outermost pair just past the silhouette where + // they read as something further down in the goo rather than as + // nothing at all. + // The base placement carries NO clock. Everything time-varying — + // the wobble, how proud it rides, the blink — is added after the + // spread, so the spread itself is a pure function of the count + // and the shape and can simply be remembered. + const float bent = (2.0f * ua - 1.0f) * 3.14159265f * 0.62f; + + // The low third of them go in the PUDDLE, not on the wall. + // + // Which third is not a separate draw — it is the bottom of the + // same height sequence, so the two populations cannot both grow + // or both empty out. The spill is a nearly horizontal sheet and + // the body is a nearly vertical one, so an eye in it is looking + // up out of a flat pool rather than out of a wall; that falls out + // of the seat's own normal and needs nothing said here. + constexpr float kPoolShare = 0.22f; + const bool inPool = ub < kPoolShare; + + // Stopping short of the surface, and how far short depends on + // which way round the body it is. + // + // The camera looks DOWN. An eye on the far side of the body is + // therefore drawn HIGHER on screen than one at the same height in + // front of it, by about twice the body's radius times the sine of + // the tilt — here roughly a third of the whole range. Left with + // one ceiling for all of them, the ones at the back climbed over + // the bin's front rim; and because the gel's lid is cut away + // exactly there, they did not disappear behind the goo, they + // appeared to float in the bin's open mouth. + // + // The front gets the higher ceiling anyway. The gel's collar has + // run down the OUTSIDE of the bin and sits lower than the liquid + // within it, so even there the top of the range is not the top of + // the gel. + const float back = 0.5f * (1.0f - std::cos(bent)); + const float top = 0.90f - 0.34f * back; + const float height = 0.16f + (top - 0.16f) * (0.94f * ub + 0.06f * seedB); + + slot[i].angle = bent; + slot[i].param = inPool ? 0.14f + 0.62f * (ub / kPoolShare) : height; + slot[i].radius = radius; + slot[i].pool = inPool; + } + + // Nothing above guarantees they do not touch, so this does — and it + // is the same answer every frame until the bin itself moves, so it is + // worked out then and kept. + const float key = m_shape.surfaceY(m_contentLine, m_sim.level()) + + 7.0f * m_shape.poolTop() + 13.0f * binW; + if (n != m_spreadCount || !qFuzzyCompare(key, m_spreadKey)) { + spreadEyes(slot, n); + std::copy(slot, slot + n, m_spread); + m_spreadCount = n; + m_spreadKey = key; + } else { + std::copy(m_spread, m_spread + n, slot); + } + + for (int i = 0; i < n; ++i) { + const float fi = float(i); + const float seedA = eyeHash(fi, 1.0f); + const float seedB = eyeHash(fi, 2.0f); + const float seedC = eyeHash(fi, 3.0f); + const float radius = slot[i].radius; + + // The wobble goes on HERE, after the spread. About a degree of + // turn and a hair of rise — an order of magnitude under the gap + // the spread leaves, so it can shift an eye without ever walking + // one back into its neighbour. + const float angle = slot[i].angle + 0.020f * std::sin(t * 0.21f + fi * 1.7f); + const float param = slot[i].param + + (slot[i].pool ? 0.0f + : 0.008f * std::sin(t * 0.27f + fi * 2.3f)); + const EyeSeat seat = slot[i].pool ? poolSeat(param, angle) + : bodySeat(param, angle); + + // The whole of the motion, and it runs along the normal: an eye + // rises out of the gel and settles back into it. + // + // Measured in the EYE's own radius rather than the body's, which + // is what lets one number serve a wall and a puddle at once — and + // the eyes vary in size by nearly two to one, so a distance fixed + // against the bin sat the small ones deeper than the large ones + // for no reason anyone could see. + // + // The useful range is narrow and not obvious. At 0 the ball's + // centre is ON the surface and half of it stands clear, which is + // already enough to expose the pupil; the gel's own lumps swing + // by more than this does, so the low end of the range is where + // they close over it. Much past 0.6 and it is a bead resting on + // top. Each eye breathes at its own rate so they never pulse + // together. + const float proud = radius * (0.30f + + 0.32f * std::sin(t * (0.28f + 0.14f * seedA) + seedC * 6.2831853f)); + + // Blinking, and only how MUCH — the lids themselves are drawn on + // the eyeball, in shaders/eye3d.frag, because the gel is 64 + // segments round and one of them is as wide as a whole eye. + // + // Not a symmetric fade. A real lid slams shut in about a tenth of + // a second, rests there, and opens back at half the speed, and + // that asymmetry is most of what makes a blink read as a blink + // rather than as something dissolving. Each eye keeps its own + // period and phase, so nine of them never go together. + constexpr float kShut = 0.11f, kHold = 0.05f, kOpen = 0.21f; + const float period = 3.2f + 4.6f * seedB; + const float phase = std::fmod(t + seedC * period, period); + float open = 1.0f; + if (phase < kShut) { + open = 1.0f - phase / kShut; + } else if (phase < kShut + kHold) { + open = 0.0f; + } else if (phase < kShut + kHold + kOpen) { + const float o = (phase - kShut - kHold) / kOpen; + open = o * o * (3.0f - 2.0f * o); + } + + m_eyeSpheres.append( + QVariant::fromValue(QVector4D(seat.pos + seat.nrm * proud, radius))); + m_eyeNormals.append(QVariant::fromValue(QVector4D(seat.nrm, open))); + } +} + } // namespace hyperbin #include "OozeEffect.moc" diff --git a/src/effects/OozeEffect.h b/src/effects/OozeEffect.h index 1de569c..461085c 100644 --- a/src/effects/OozeEffect.h +++ b/src/effects/OozeEffect.h @@ -12,7 +12,10 @@ #include #include +#include #include +#include +#include namespace hyperbin { @@ -38,8 +41,37 @@ class OozeEffect : public Effect Q_PROPERTY(float rimY READ rimY NOTIFY shapeChanged) Q_PROPERTY(float rimRadius READ rimRadius NOTIFY shapeChanged) Q_PROPERTY(QObject *iconTexture READ iconTexture NOTIFY shapeChanged) + /// The eyes, one entry each, only as many as are currently out. + /// + /// (x, y, z, radius) in bin-local units. Two consumers want this and + /// they want it in different places — qml/OozeEyes.qml puts a model + /// there, and the gel's vertex shader tents its surface up over it — + /// so it is computed once, here, against the same profile the mesh is + /// swept from. Working it out in QML was the earlier arrangement and + /// it could not answer the second question at all: the scene knows + /// where it PUT an eye, but not which way the gel faces there. + Q_PROPERTY(QVariantList eyeSpheres READ eyeSpheres NOTIFY frameChanged) + /// (x, y, z, open) — the gel's outward normal where each eye sits, + /// and how open that eye is, 0 shut to 1 wide. The scene leans a gaze + /// by the first and hands the second to the eyeball's own material, + /// which is where the lids are drawn. + Q_PROPERTY(QVariantList eyeNormals READ eyeNormals NOTIFY frameChanged) + /// How many there can ever be. The scene builds this many delegates + /// once and hides the ones that are not out; rebuilding the Repeater3D + /// whenever the count moved cost four times the whole effect when the + /// bubbles did it. + Q_PROPERTY(int maxEyes READ maxEyes CONSTANT) public: + /// The most eyes that can ever be out, at a full bin. + /// + /// Every one of these is a slot in the gel's shaders as well — the + /// body has to swell around each and draw a meniscus at its rim, and + /// a CustomMaterial has no array uniform, so the count is written out + /// by hand in three places. Raising it means raising it there too; + /// qml/OozeVisual.qml has the list. + static constexpr int kMaxEyes = 14; + explicit OozeEffect(QObject *parent = nullptr); ~OozeEffect() override; @@ -80,6 +112,10 @@ class OozeEffect : public Effect float rimRadius() const { return m_shape.rimRadius(); } QObject *iconTexture() const; + QVariantList eyeSpheres() const { return m_eyeSpheres; } + QVariantList eyeNormals() const { return m_eyeNormals; } + int maxEyes() const { return kMaxEyes; } + const OozeSim &sim() const { return m_sim; } /// The gel's silhouette, measured from the bin's artwork. Read by /// OozeGeometry, which sweeps the mesh from it — bulk data, so it is @@ -93,8 +129,77 @@ class OozeEffect : public Effect void shapeChanged(); private: + /// A place on the gel to sit an eye: a point, and which way the gel + /// faces there. + /// + /// The two always travel together. Working the normal out separately + /// meant two functions walking the same profile and either could be + /// changed without the other, which is the drift this whole file is + /// arranged to prevent. + struct EyeSeat + { + QVector3D pos; + QVector3D nrm; + }; + + /// One eye's placement, before it is turned into a seat. + struct EyeSlot + { + float angle; ///< radians round the bin; 0 faces the camera + float param; ///< 0..1 up its own surface, body or puddle + float radius; + bool pool; + }; + + /// Recompute where the eyes are. Called every step. + void updateEyes(); + + /// Push any two that would touch apart, in place. + /// + /// The placement sequences spread well but do not GUARANTEE a gap, + /// and at fourteen they demonstrably fail to: the golden angle has + /// near-returns at Fibonacci indices, so eyes 0 and 13 come back to + /// within a fiftieth of a turn of each other. Nothing about the + /// sequence can be tuned to fix that in general — the next count + /// would just fail at a different pair — so the gap is enforced here + /// instead of hoped for. + void spreadEyes(EyeSlot *slot, int n) const; + /// Where a slot's parameter puts it, in bin-local units. Body and + /// puddle share a floor, so this is one line either way. + float slotY(const EyeSlot &s) const; + + /// On the body's wall. + /// @param t 0 at the pool's crest, 1 at the gel's surface. + /// @param angle radians around the bin's axis; 0 faces the camera. + EyeSeat bodySeat(float t, float angle) const; + /// On the puddle's upper surface, which is a different shape and a + /// nearly horizontal one — so an eye there is looking up out of a + /// flat spill rather than out of a wall, and its normal has to come + /// from the shoulder the mesh actually sweeps. + /// @param u 0 at the puddle's outer rim, 1 where it meets the body. + EyeSeat poolSeat(float u, float angle) const; + + /// Both seats are the same surface of revolution seen twice: a ring + /// radius and a height that each move with one parameter. Given how + /// fast they move, the normal is the same three lines either way. + static EyeSeat sweepSeat(float r, float y, float drdp, float dydp, + float angle); + OozeSim m_sim; OozeShape m_shape; + QVariantList m_eyeSpheres; + QVariantList m_eyeNormals; + /// The last spread, and what it was spread against. + /// + /// Where the eyes settle depends only on how many there are and on + /// the shape they are settling on — never on the clock. The wobble + /// each carries is added afterwards and is far smaller than the gap + /// the spread leaves, so it cannot push a pair back together. So the + /// relaxation runs when the bin changes and not otherwise, which for + /// a bin that is mostly sitting still is almost never. + EyeSlot m_spread[kMaxEyes] {}; + int m_spreadCount = -1; + float m_spreadKey = 0.0f; QSizeF m_binSize {40.0, 40.0}; QRectF m_binRect; float m_contentLine = 0.22f; diff --git a/src/effects/OozeGeometry.cpp b/src/effects/OozeGeometry.cpp index 8d910e6..690ebcd 100644 --- a/src/effects/OozeGeometry.cpp +++ b/src/effects/OozeGeometry.cpp @@ -273,8 +273,11 @@ void OozeGeometry::rebuild() push(innerR * std::cos(a), bodyTop + innerR * 0.13f * std::sin(a)); } // --- the sweep --------------------------------------------------------- - constexpr int kU = 64; // around, all the way round - constexpr float kDepth = 0.86f; // the bin is a little deeper than wide + constexpr int kU = 64; // around, all the way round + // The bin is a little deeper than wide. Shared with OozeShape so that + // whatever else gets placed on this surface is squashed by the same + // amount the surface is. + constexpr float kDepth = OozeShape::kDepth; const int nr = outline.size(); // A true solid of revolution, and it has to stay one. diff --git a/src/effects/OozeShape.h b/src/effects/OozeShape.h index 9dfd5db..bdea86f 100644 --- a/src/effects/OozeShape.h +++ b/src/effects/OozeShape.h @@ -19,6 +19,16 @@ namespace hyperbin { class OozeShape { public: + /// How much the body is flattened front to back when it is swept. + /// + /// The bin is a little deeper than it is wide, so the sweep squashes + /// z. It lives here rather than in OozeGeometry because anything that + /// puts something ON the surface has to apply it too: eyeAt() did not, + /// and an eye facing the camera came out a seventh of the body's + /// radius clear of the gel it was supposed to be sitting in — which + /// only showed at the front and back, where cos(angle) is largest. + static constexpr float kDepth = 0.86f; + /// Measure the bin's taper from its silhouette. Safe to call with an /// empty grid: falls back to a generic tapered barrel so the effect /// still draws something before the first artwork arrives.