Bloom Passage
Bloom Passage
Bloom Passage - Thin-Petal Volumetric Flower Cavern
Note: Most of the code in this post was written by AI.
The prose was written by a human, but most of the formulas and code were written by AI.
bloom-passage is a WebGL2 volumetric ray-marching scene in which the camera travels through a winding cavern, while elliptical buds growing inward from the walls and ceiling bloom as the camera approaches.
The petals are not thick SDF solids or CPU-generated meshes. Instead, they are represented as very thin density layers around curved midsurfaces, and their emission, absorption, and transmittance are integrated along each ray together with the surrounding rock density.
The design constraints of the current implementation are as follows.
Bloom state is driven solely by camera distance, not by time-based random values.
Petal attachment points are fixed, and petals split open from the free end.
Petals are sheet densities 8.8–10.2 mm thick rather than elliptical cross-section tubes.
Ray-cast flower petals close the outer end of the midline with an elliptical cap while preserving tip width, so the free end does not come to a sharp point.
The white tissue is nearly opaque due to a high absorption coefficient, but retains only a faint transmission at the edges.
A single stem endpoint owns the stem, sepal, floret center, and petal frame all at once.
The stems of all flowers are aligned to the local normal pointing from the cavern's outer wall toward its center.
The final color is obtained via Beer-Lambert front-to-back integration rather than a surface hit.
1. Curved Cavern and Radial-Axial Repetition
The cavern centerline is the sum of two frequencies with respect to the travel coordinate z.
C(z) = (
0.82sin(0.18z) + 0.24sin(0.47z + 1.30),
0.55cos(0.15z + 0.40) + 0.20sin(0.39z − 0.70)
)The cross-section radius is the sum of a low-frequency breathing component and angular rock-wall protrusions.
R(z,θ) = 2.28 + 0.16sin(0.21z+0.50) + 0.07sin(0.53z−0.80)
+ 0.13sin(3θ+0.31z) + 0.065sin(7θ−0.47z)
+ 0.035sin(11θ+0.29z)The axial direction repeats with L_z=2.65 and each cross-section repeats across N=6 sectors. Each axial cell is rotated by a hash, and each flower's axial position and angle are further jittered within a small range.
k_z = floor((p_z + L_z/2)/L_z)
Δθ = 2π/N
k_θ = floor((θ − φ(k_z) + Δθ/2)/Δθ)
θ_a = φ(k_z) + k_θΔθ + jitter(hash(k_z,k_θ))The base point of a flower is C(z_a)+R(z_a,θ_a)(cosθ_a,sinθ_a). The outward normal n_o, circumferential tangent t, inward normal n_i, and axis a form the flower's local frame.
n_o = (cosθ_a, sinθ_a, 0)
t = (−sinθ_a, cosθ_a, 0)
n_i = −n_o
a = (0,0,1)
q = (dot(p−b,t), dot(p−b,n_i), dot(p−b,a))As a result, all flowers face the interior of the cavern without needing to separately rotate and replicate the same flower model for the floor, side walls, and ceiling. Using only the wall's inward normal as the stem axis means that the thin corolla on the 3 o'clock and 9 o'clock side walls becomes precisely edge-on to a forward-moving camera. Rather than using a screen-aligned billboard, an upstream tilt ℓ_z fixed to each cell is applied so that the flower's physical orientation is preserved while projection singularities are avoided.
ℓ_z = −mix(0.24,0.30,hash(k_z,k_θ))
s = normalize(ℓ_t t + h_stem n_i + ℓ_z a)In a curved cavern, the difference between C(p_z) and the ring reference C(z_ring) can cause a flower's bounding sphere to exceed its nominal angular cell. Evaluating only one cell causes the flower ID to switch abruptly at that point, clipping the corolla flat. The implementation evaluates only the nearest angular neighbors together within a boundary strip. In the axial direction, neighbors are not redundantly re-evaluated. The sum of anchor jitter, stem tilt, wind, and the flower head's bounding sphere is kept smaller than L_z/2, ensuring that exactly one owner is assigned to each axial ring.
θ_local = θ − φ(k_z) − k_θΔθ
d_boundary = |p_xy−C(z_ring)| sin(Δθ/2−|θ_local|)
d_boundary < 0.76 이면
ρ_flower = max(ρ_kθ, ρ_kθ±1), D_flower = min(D_kθ, D_kθ±1)
|z_jitter| + |lean_z| + |wind_z| + r_head < L_z/2Both sides of an angular boundary evaluate the same two candidates in opposite order. Density max, distance min, and material-weighted averages all satisfy commutativity, so results are continuous across sector planes. Axial rings evaluate only a single candidate.
Personal note: in practice, when browsing ShaderToy, experienced shader programmers use ray marching frequently. This work represents a first step in that application, using Signed Distance Functions (SDFs) defined procedurally inside the shader along with domain repetition techniques.
2. Bloom State Determined Solely by Distance
The distance d between the flower center c and the camera r_o is converted to a bloom amount O.
P(d) = clamp((d_far − |c − r_o|)/(d_far − d_near), 0, 1)
P_η = clamp(P + 4ηP(1−P), 0, 1), η ∈ [−0.09,0.09]
O(d) = P_η²(3 − 2P_η)When d ≥ d_far, O=0; when d ≤ d_near, O=1. Time is used only for stem wind and subtle petal oscillation. Accordingly, there is no state reversal where a flower extremely close to the camera reverts to a bud.
3. Opening from the Free Tip of an Elliptical Bud
The length coordinate of a petal is set as u∈[0,1], where u=0 is the base attached to the stem and u=1 is the free tip. The actual cubic Bézier control points for a closed petal and an open petal are as follows.
P₀ = (r_b, y_b)
P₁_closed = (r_b + 0.76b, y_b + 0.31h)
P₂_closed = (r_b + b, y_b + 0.72h)
P₃_closed = (r_b, y_b + h)
P₁_open = (r_b + 0.24R, y_b + 0.78A)
P₂_open = (r_b + 0.67R, y_b + 0.62A − 0.18G)
P₃_open = (r_b + R, y_b − G)h is the bud height, b is the bud inflation, R is the open length, A is the crown height, and G is the per-petal gravitational droop. The midplane is not blended separately for each petal. Control points P₁, P₂, P₃ are set as the base, shoulder, and free tip respectively, and a single cubic Bézier skeleton is maintained throughout. With g(x)=x²(3−2x), all three control points begin moving from O=0, but the free tip moves first, followed by the shoulder and then the base.
r = g(O)
r_tip = r
r_shoulder = r(0.58 + 0.42O)
r_hinge = r(0.28 + 0.72O)
P₁ = mix(P₁_closed, P₁_open, r_hinge)
P₂ = mix(P₂_closed, P₂_open, r_shoulder)
P₃ = mix(P₃_closed, P₃_open, r_tip)
c(u,O) = (1−u)³P₀ + 3(1−u)²uP₁ + 3(1−u)u²P₂ + u³P₃With this structure, a single petal never simultaneously contains a closed curve segment and an open curve segment. Because the value and first-order slope of every release function are zero at the start point, only the free tip moves first, and there is no initial edge pop that used to produce an instantaneous kink at the shoulder. It also structurally prevents the self-fold that caused double coronas to regenerate during blooming in the earlier implementation. The base distance range has also been widened from 2.2→7.5 m to 1.6→10.8 m, so that at default speed blooming takes about 8.8 seconds rather than approximately 5 seconds.
Each flower selects one of 28, 30, 32, 34 corona petals via a hash. Overall length, width, droop, floret size, and bloom phase also vary per cell, and small individual deviations in each petal's length and width grow continuously from zero according to g(O). As a result, the closed calyx does not split open, and shapes are not suddenly activated at a critical bloom threshold.
4. Thin Petal Density Field
Petals are not divided into straight ribbon segments. For a sample q_xy, the closest parameter u along the current-bloom midline c(u,O) is found. With r=c(u,O)−q_xy, the objective function is F(u)=|r|²/2.
F′(u) = dot(r,c′)
F″(u) = dot(c′,c′) + dot(r,c″)
δ_k = clamp(F′/safe(F″), −0.16, 0.16)
u_{k+1}= clamp(u_k − δ_k, 0, 1)
n = dot(q_xy − c(u,O), normalize(−c′_y,c′_x))The derivative uses the analytic first- and second-order derivatives of the cubic Bézier, not finite differences.
c′(u) = 3(1−u)²(P₁−P₀) + 6(1−u)u(P₂−P₁) + 3u²(P₃−P₂)
c″(u) = 6(1−u)(P₂−2P₁+P₀) + 6u(P₃−2P₂+P₁)A bud curve folds once in the radial direction and then becomes a monotone curve as it blooms. Because of this, an initial guess derived from height or radius alone can converge to the wrong curve branch in intermediate states. The implementation selects the global minimum basin from nine candidates over [0,1], then applies four Newton correction steps using the full Newton denominator.
u_0 = argmin_{j∈{0,…,8}} |c(j/8,O)−q_xy|²
u ← Newton(F′,F″), 4 iterationsThe argmin over the discrete candidates does not change the distance value itself; it only selects Newton's basin. The actual distance and density are computed at the corrected curve point, which prevents the path where a wrong branch used to diverge into long white fragments during blooming. The tangent residual is not used as a conservative sphere-tracing step distance. Ray-cast flower petals are narrow near the floret center and wider in the middle. Rather than forcing the width to zero at u=1, M_end includes the off-midline distance after the nearest point clamps to the endpoint, forming an elliptical cap.
b(u) = smootherstep(clamp(u/0.13,0,1))
g(u) = mix(0.50,1,smootherstep(clamp((u−0.06)/0.44,0,1)))
t(u) = 1 − 0.12 smootherstep(clamp((u−0.70)/0.30,0,1))
w_max(O) = mix(0.023,0.0355,O) · scale_width
w(u,O) = w_max(O) · b(u) · g(u) · t(u)Angular repetition does not simply clip out only the current petal within one sector. Both the current petal i and its neighbors i±1 at the sector boundaries are evaluated together, and a gate χ that is 0 at the sector center and 1 at the boundary continuously activates the neighbors.
χ = smootherstep(clamp((|φ_local|−0.14Δθ)/(0.36Δθ),0,1))
ρ_corona = max(ρ_i, χρ_{i±1})As a result, even when a petal's width exceeds the sector's half-width, the petal is not clipped by a radial straight line; instead, adjacent petals can overlap slightly, just as they do in real flowers.
A thin sheet is formed by multiplying the Gaussian density of the mid-surface by an edge mask.
ρ_p(q) = exp(−2.20(n/τ)²) · M_side(|q_z|/w) · M_end · M_nearest
τ ∈ [0.0088, 0.0102]The color transition from the bud's green tissue to white ray petals is not switched on by a single O value for the entire corolla. A separate pigment field that starts at the free tip u=1 and propagates toward the base u=0 is stored in the density field.
O_start(u) = mix(0.48,0.34,u)
W(u,O) = smootherstep(clamp((O−O_start(u))/0.34,0,1))
ρ_white = ρ_p · WAccordingly, white does not appear on the early corolla while it is still folded into a cup shape. Only after the petal blade has actually separated does white begin at the free tip and work its way down toward the base.
The cell ID for angular repetition must be normalized by the corona count. Because the return value of atan wraps between +π and −π at the same direction, using the raw integer in a hash makes the two sides of the same flower or petal evaluate as different objects.
i_flower = mod(floor((θ−φ+Δθ/2)/Δθ), N_flower)
i_petal = mod(floor((ψ+δψ/2)/δψ), N_petal)In the version without this normalization, a flower straddling the branch cut was evaluated as two independent halves, causing the center to be clipped or producing white semicircular fragments.
Open petals lift their edges in proportion to the square of the lateral coordinate. This cupping bends the mid-surface itself rather than thickening the cross-section. The central midrib ρ_v, parallel micro-ridges ρ_r, and marginal tissue ρ_e are computed from u and the normalized lateral coordinate. Because these are procedural surface coordinates that are attached to and move with the petal rather than being external textures, they do not slide during blooming.
M_nearest smoothly discards only those samples whose tangential residual from the Newton result is large. This value serves solely as a safety check on visible density and is not used as the basis for skipping large stretches of empty space.
5. Single Coordinate Owner for Rock Anchors and Flower Heads
In the local frame with the cave wall anchor b as the origin and +Y=n_i, the stem endpoint is the following single point.
h = (lean_t, stemHeight, lean_a)
D_stem(q) = sdCapsule(q, 0, h, r)
c_flower = b + h_x t + h_y n_i + h_z aThe flower head's local +Y axis is aligned to normalize(h).
q_head = (
dot(p−h, x̂),
dot(p−h, ŷ),
dot(p−h, ẑ)
)Because the stem capsule and flower head share the same rock anchor and frame, the sepals and petals never detach from the stem regardless of whether they are on a wall or ceiling, or whether wind is blowing.
6. Floor Rock and Moss Surface Field
The floor is not a 2D layer placed at the bottom of the screen. A floor mask is constructed from how strongly the cave cross-section's radial vector r_xy points downward, and moss and fine moisture veins are placed using low-frequency noise over the wall coordinates (x,z).
M_floor = smootherstep(clamp((−r_y/|r|−0.24)/0.56,0,1))
ρ_moss = ρ_wall M_floor smoothstep(0.46,0.72,N_moss)
ρ_vein = ρ_wall M_floor (1−|2N_f−1|)^11 smoothstep(0.50,0.76,N_moss)Low pebbles are elliptical footprints whose position, aspect ratio, and height are hashed per (x/0.58, z/0.76) cell. The pebble height h_s∈[0.030,0.095] shifts wallGap inward by that amount so the pebbles participate in the silhouette and occlusion of the actual volume. Moss and veins tint the same wall density, so they never float in midair.
7. Emission–Absorption Volumetric Ray Marching
The extinction coefficient at each sample is the weighted sum of petal, vein, stem, floret center, and sepal densities.
σ_t = ρ_p(112 + 14ρ_r + 4ρ_e) + 58ρ_s + 72ρ_c + 54ρ_k + 44ρ_wBeer-Lambert transmittance and scattered light are updated front to back at each sample interval Δs.
T_step = exp(−σ_t Δs)
α = 1 − T_step
L_{k+1} = L_k + T_k · S_k · α
T_{k+1} = T_k · T_stepS_k is a source term combining the material color, the density derivative in the sun direction, and a forward/backward scattering approximation. The base extinction coefficient for petals is raised from 38 to 112 so that the white tissue appears nearly opaque when viewed head-on, while the Gaussian tail of the sheet and the thin edges retain T to preserve backlit transmission.
8. Adaptive Sample Spacing and Bounds
Empty space is skipped using a conservative distance d_macro to the stem capsule, flower head bounding sphere, petal plane, and cave wall. The flower head bounding sphere is used as an acceleration distance only from outside. Negative values inside the bounding sphere do not correspond to actual material, so once a ray enters the interior, the petal, core, and sepal distance fields take ownership. Because a pixel is a cone rather than a line, any step narrower than its width is wasted marching through something the ray cannot resolve.
D_head⁺ = max(D_head,0)
Exterior: d_macro = min(D_stem,D_head⁺)
Inside the detailed model: d_macro = min(D_stem,D_core,D_calyx,D_petal)w_px = (2 / (H · 1.78)) · s Pixel cone width
Δs = clamp(max(0.60 d_macro, w_px), 0.007, 1.60)
σ_t > 0.025이면 Δs = clamp(0.35 / σ_t, 0.007, Δs)Sampling is capped at 128 iterations, with early termination at T<0.012 and a maximum distance of 20 m. The cave rock wall is included in the same emission-absorption integral, so occlusion behind flowers is not separated into a distinct 2D layer. The internal render resolution is dynamically adjusted between 34% and 100% of the screen size based on measured FPS.
d_macro is a proximity estimate, not a Signed Distance Function. The petal sheet value is obtained by approximating the nearest point on the Bézier curve via Newton's method, the floret center and sepal use an ellipsoid approximation, and the wall uses the radial gap. Therefore, sphere tracing with the full d_macro step cannot guarantee the ray stays outside the surface. This is why the coefficient is reduced from 0.85 to 0.60, and the spacing inside material is governed by optical depth rather than a fixed lower bound. Both values were chosen through measurement — see the "Problem of Steps Skipping Over Petals" section below.
9. Camera
The camera travels along the same C(z) as the GLSL cavern and looks 3.35 units ahead. Instead of a fixed world-up vector, a local frame built from the direction of travel and curvature is used, and two low-frequency rolls are added to create the sense of directional change that makes the cavern feel like it is actually being explored.
r_o = path(z)
r_t = path(z+3.35)
f = normalize(r_t−r_o)
s = normalize(f × y_world)
u_0 = normalize(s × f)
roll(z) = 0.23sin(0.17z) + 0.09sin(0.43z+1.20)
u = u_0 cos(roll) + s sin(roll)Drag input moves only the look-ahead target along these local s,u directions. Flowers that pass through the camera naturally fall out of the forward ray range and leave no separate state for flowers behind it.
Formula → Code
Formula/Concept | Implementation Location | Role |
|---|---|---|
|
| Curved cavern centerline and rock wall boundary |
Axis-angle repetition cell |
| Wall and ceiling flower anchor selection |
upstream stem lean |
| Preventing edge-on projection collapse of the 3 o'clock and 9 o'clock sidewall corollas |
|
| Preventing the entire flower from being clipped at sector planes; the axial ring is single-owned as a placement invariant |
varied cubic |
| Slow distance-based blooming and per-cell phase offset |
continuous staged Bézier |
| Persistent skeleton where all segments depart simultaneously and the free end leads |
ray-cast flower petal |
| Narrow base, wide blade, and arc-shaped free end |
global basin + analytic Newton |
| Nearest-point computation that does not diverge onto a spurious curve branch even for a folded bud |
neighbor-gated petal union |
| overlapping ray-cast flower petals without sector clipping |
|
| thin sheet density with tangential residual boundary |
|
| central midrib, parallel fibers, and marginal tissue |
|
| white pigment transition progressing from the free tip toward the base |
angular corona repetition |
| a single-layer corolla of 28–34 petals per flower |
canonical angular IDs |
| Preventing flowers and petals from splitting in half at the |
Rock-face local frame and |
| Wall normal alignment and flower head tip position |
|
| Ground moss, moisture veins, and low pebbles attached to the cavern wall |
|
| Beer-Lambert transmittance |
|
| Front-to-back scattering integral |
adaptive |
| Empty-space acceleration and thin-petal preservation |
look-ahead + roll camera |
| Curved-cavern travel direction and gaze alignment |
- Formula/Concept
C(z),R(z,θ)- Implementation Location
shaders/flower-model.glsl- Role
Curved cavern centerline and rock wall boundary
- Formula/Concept
Axis-angle repetition cell
- Implementation Location
shaders/flower-model.glsl- Role
Wall and ceiling flower anchor selection
- Formula/Concept
upstream stem lean
ℓ_z- Implementation Location
shaders/flower-model.glsl- Role
Preventing edge-on projection collapse of the 3 o'clock and 9 o'clock sidewall corollas
- Formula/Concept
d_boundary, angular adjacent-cell union- Implementation Location
shaders/flower-model.glsl- Role
Preventing the entire flower from being clipped at sector planes; the axial ring is single-owned as a placement invariant
- Formula/Concept
varied cubic
O(d)- Implementation Location
shaders/flower-model.glsl- Role
Slow distance-based blooming and per-cell phase offset
- Formula/Concept
continuous staged Bézier
c(u,O)- Implementation Location
shaders/flower-model.glsl- Role
Persistent skeleton where all segments depart simultaneously and the free end leads
- Formula/Concept
ray-cast flower petal
w(u,O)- Implementation Location
shaders/flower-model.glsl- Role
Narrow base, wide blade, and arc-shaped free end
- Formula/Concept
global basin + analytic Newton
u- Implementation Location
shaders/flower-model.glsl- Role
Nearest-point computation that does not diverge onto a spurious curve branch even for a folded bud
- Formula/Concept
neighbor-gated petal union
- Implementation Location
shaders/flower-model.glsl- Role
overlapping ray-cast flower petals without sector clipping
- Formula/Concept
ρ_p=exp(−2.20(n/τ)²)M- Implementation Location
shaders/flower-model.glsl- Role
thin sheet density with tangential residual boundary
- Formula/Concept
ρ_v,ρ_r,ρ_e- Implementation Location
shaders/flower-model.glsl- Role
central midrib, parallel fibers, and marginal tissue
- Formula/Concept
ρ_white=ρ_pW(u,O)- Implementation Location
shaders/flower-model.glsl- Role
white pigment transition progressing from the free tip toward the base
- Formula/Concept
angular corona repetition
- Implementation Location
shaders/flower-model.glsl- Role
a single-layer corolla of 28–34 petals per flower
- Formula/Concept
canonical angular IDs
mod(i,N)- Implementation Location
shaders/flower-model.glsl- Role
Preventing flowers and petals from splitting in half at the
atanbranch cut
- Formula/Concept
Rock-face local frame and
D_stem- Implementation Location
shaders/flower-model.glsl- Role
Wall normal alignment and flower head tip position
- Formula/Concept
ρ_moss,ρ_vein,ρ_stone- Implementation Location
shaders/flower-model.glsl- Role
Ground moss, moisture veins, and low pebbles attached to the cavern wall
- Formula/Concept
T=exp(−σ_tΔs)- Implementation Location
shaders/render.frag- Role
Beer-Lambert transmittance
- Formula/Concept
L+=T·S·α- Implementation Location
shaders/render.frag- Role
Front-to-back scattering integral
- Formula/Concept
adaptive
Δs- Implementation Location
shaders/render.frag- Role
Empty-space acceleration and thin-petal preservation
- Formula/Concept
look-ahead + roll camera
- Implementation Location
main.js- Role
Curved-cavern travel direction and gaze alignment
File structure
quad.vert
↓
common.glsl — hash / noise / primitive / tone mapping
↓
flower-model.glsl — tunnel frame / wall anchors / petal sheet density
↓
render.frag — flower + cave volume integration / scattering
↓
main.js — static manifest / camera / uniforms / adaptive qualityThe static SHADER_URLS manifest in main.js explicitly loads all GLSL files. No external images, external textures, or CPU-side flower meshes are used.
Controls
The panel is hidden by default; press
Hto open it.Flight speedcontrols the camera movement speed.Flowers within
Near bloomdistance open fully.Flowers beyond
Far bud limitremain as buds.Dragging the canvas adjusts the gaze direction relative to the current travel direction.
New cavernregenerates the seed for flower positions and shapes.Spacepauses the camera and wind.
Troubleshooting issues encountered during implementation
The metric is the temporal second-order difference of pixel luminance. For a smoothly moving signal, the second-order difference should be much smaller than the first-order difference. If it is large, the value is flipping direction every frame, which is flickering. The ratio of pixels where |second-order difference| > 12/255 is recorded as popPct.
The release build at 765×595 showed popPct 4.22%, d2 2.583 > d1 1.706.
What was NOT the cause (all ruled out by measurement)
Experiment | popPct |
|---|---|
Original | 4.22% |
Resolution 270 → 1800 px | 4.66 / 4.61 / 4.63 / 4.63% |
Step count 84 → 320, max step 0.26 → 0.05 | 4.61% |
Min step 7 mm → 1.5 mm | 4.49% |
Optical depth per step capped at 0.35 | 4.60% (cost +40%) |
Petal count 28–34 → 8–10 | 3.70% |
Shutter 2-tap average | 6.11% (worse) |
- Experiment
Original
- popPct
4.22%
- Experiment
Resolution 270 → 1800 px
- popPct
4.66 / 4.61 / 4.63 / 4.63%
- Experiment
Step count 84 → 320, max step 0.26 → 0.05
- popPct
4.61%
- Experiment
Min step 7 mm → 1.5 mm
- popPct
4.49%
- Experiment
Optical depth per step capped at 0.35
- popPct
4.60% (cost +40%)
- Experiment
Petal count 28–34 → 8–10
- popPct
3.70%
- Experiment
Shutter 2-tap average
- popPct
6.11% (worse)
The decisive clue was that raising the resolution 6.7× made no difference. Spatial aliasing would not behave that way. When the camera was held still (speed = 0), popPct dropped to 0.03%, ruling out temporal terms such as wind and bloom state.
Root cause
Results measured while reducing dt.
dt | d1 | d2 | d2/d1 | popPct |
|---|---|---|---|---|
1/60 | 1.87 | 2.90 | 1.55 | 4.73 % |
1/240 | 0.62 | 0.67 | 1.09 | 1.19 % |
1/960 | 0.185 | 0.177 | 0.96 | 0.22 % |
- dt
1/60
- d1
1.87
- d2
2.90
- d2/d1
1.55
- popPct
4.73 %
- dt
1/240
- d1
0.62
- d2
0.67
- d2/d1
1.09
- popPct
1.19 %
- dt
1/960
- d1
0.185
- d2
0.177
- d2/d1
0.96
- popPct
0.22 %
The density field is smooth. What was lacking was the frame rate. d1 decreases linearly with dt, and so does d2, which is characteristic of a signal where a hard boundary crosses a pixel on screen. At the base speed of 0.82, a nearby flower travels 8.2 pixels per frame, and its silhouette is a bright white edge against an almost-black cavern.
So instead of motion, it becomes a strobe effect. Erasing the density terms one by one, removing only the petal term dropped popPct from 4.23% to 0.67%.
In other words, this was understood not as a marching bug but as a sampling limitation. The complete fix would be temporal accumulation (TAA) or a slower camera, but neither was practical.
What was done and what was rolled back
Base speed 0.82 → 0.50: This is the only knob that responds nearly linearly to this metric.
At 765×595, popPct was 4.28% at 0.82, 3.49% at 0.55, and 0.94% at 0.35. The slider still went up to 1.25, but the result was visually poor.
Per-pixel shutter was rolled back: offsetting each pixel's camera viewpoint differently until the next frame
did reduce the spatial coherence of the strobe numerically, but on nearby white flowers it made the outlines look like long white fragments with a cut through the center. This partial artifact was somewhat problematic. Currently, all pixels in a single frame share the same camera transform, retaining only a small spatial dither of the ray origin.
That is, without temporal accumulation, motion flicker still exists, but instead of that, shape-breaking false motion blur has been eliminated and sampling error has been mitigated by lowering the base speed.
The problem of rays dying after exhausting their budget
While working with AI, a new bug appeared:
it was found in the code below.
if (field.macroDistance < 0.020 || extinction > 0.025) stepSize = MIN_VOLUME_STEP;The earlier condition was the problem: rays that merely grazed a flower (roughly 2 cm from a bud) also satisfied macroDistance < 0.020 throughout a long stretch, crawling forward in 7 mm steps without encountering any material. They then exhausted the 84-step budget and exited with transmittance close to 1, causing the pixel to fall back to the background.
At the time of measurement, 17.21% of the screen was ending that way, and the map of those pixels covered the border of every flower and the entire distant passage. After removing that condition the diagnostic value became 0.00%, but the check had missed a second path where the negative interior of the bounding sphere was continuously occupying the distance field via min.
The approach GPT suggested was to let empty space skip ahead via sphere tracing, since petalDistance was already feeding into macroDistance, while ensuring that steps never became smaller than the pixel cone width (anything thinner than the cone cannot be resolved anyway), and to reduce MAX_VOLUME_DISTANCE from 26 to 20 m. Because the fog is exp(-0.008 d²), it already reaches 0.87 at 16 m and 0.96 at 20 m, so anything beyond that would be background regardless of what was found.
A side effect was that buds in the distant passage became visible.
The second failure appeared as radial black wedge shapes on the large nearby flower. Passing through a headBound of diameter 1.16 m using only 7 mm steps requires roughly 166 steps, which exceeds the 96-step budget. Rather than using D_head<0 as the actual interior distance, following Claude's advice it was replaced with a detailed distance field as shown in the formula above, so that the shader skips ahead again inside the empty corolla interior and uses minimum steps only near actual thin petals.
The resolution controller oscillation problem
renderScale was lowered when fps fell below 24 and raised when it exceeded 52, but after lowering it would go up again once performance improved, and after raising it would come back down once performance degraded. On devices operating near the threshold, the resolution pulsed every 0.7 seconds. The resolution change itself was also a noticeable visual problem.
When lowering, the ceiling was also lowered at the same time (ratchet), and raising required 3 consecutive fast ticks. The downward direction reacts immediately on a single tick.
The problem of steps skipping over petals
The nearby flower appeared shredded in a dithered pattern, and a wedge was sliced out of the flower head. On close inspection, bright pixels and background pixels were interleaved in a checkerboard pattern.
Troubleshooting steps taken to resolve it:
Attempt | Result |
|---|---|
Step budget 96 → 512 | Reduced but still present |
Petal proximity distance | No change |
Reduce | No change |
Always evaluate angular neighbor cells | Almost no change |
Evaluate two axial-direction rings | No change |
Remove bud color | Completely identical |
- Attempt
Step budget 96 → 512
- Result
Reduced but still present
- Attempt
Petal proximity distance
proximity× 0.5, × 0.25- Result
No change
- Attempt
Reduce
proximitytoplaneGapalone- Result
No change
- Attempt
Always evaluate angular neighbor cells
- Result
Almost no change
- Attempt
Evaluate two axial-direction rings
- Result
No change
- Attempt
Remove bud color
pigmentReveal- Result
Completely identical
Suspecting shading, I split the marching statistics for dark pixels and bright pixels and re-measured them, but found no meaningful difference: transmittance 0.010 vs. 0.011, material samples 26.2 vs. 24.5. Both were completely absorbed by something. The dark pixels were rays that skipped past the nearest petals and reached the back wall.
Cause 1: d_macro is not a Signed Distance Function (SDF)
Sphere tracing assumes a lower bound guaranteeing that nothing exists within a given radius. Reading d_macro at an arbitrary point and dividing that distance into 64 steps in an arbitrary direction to check for material yields only a lower bound on the violation rate, since only one direction is tested.
Violating samples | Worst-case ratio | |
|---|---|---|
Before fix | 8.06% | 0.016 |
After fix | 0.29 % | — |
- #1
Before fix
- Violating samples
8.06%
- Worst-case ratio
0.016
- #1
After fix
- Violating samples
0.29 %
- Worst-case ratio
—
In other words, 8% of samples reported as "empty" contained material, and in the worst case that material was at 1.6% of the claimed distance.
Wall density starts accumulating not at the surface but from wallEdge = 0.105 + 0.035 fineRock inward, yet the marching step was passing the full gap to the surface as the step distance. Subtracting the maximum value fineRock can produce (not its local value, since a step traverses an interval rather than a single point) reduces the error from 8.06% to 0.30%. Subtracting the local wallEdge gives 0.47%, which confirms that using the maximum is the correct approach.
The remaining error lives in the petal and ellipsoid approximations and cannot be eliminated. The step factor was therefore chosen from the measured envelope: 0.85 breaks while 0.60 is clean, and frame times are identical (6.50 ms vs. 6.52 ms at 819×460). Lowering it further to 0.45 is actually slower (7.22 ms) and over-budget samples increase rather than decrease (5.23% vs. 1.83%).
0.60 was chosen simply because it looks good to me, meaning it is a value chosen by measurement, not a proven safe bound. To make this section genuinely provable, the petal proximity distance would need to be rewritten as a true Signed Distance Function (SDF).
References and measured-value comparison
This section collects which values the demo draws from the literature and where it departs from them. Match means the implementation falls within the literature's range; deviation means the difference is either intentional or not yet corrected.
Item | Literature value | This implementation | Verdict |
|---|---|---|---|
Ratio of ray-cast flower petals to floret radius | 0.667 – 0.706 | 0.687 | Match |
Ray-cast petal count | Typically 13 – 34+ | 28 / 30 / 32 / 34 | Upper bound of range, always even |
Floret/corolla diameter | 34 – 60 mm | 0.805 m | Approximately 17× (intentional scale) |
Distal/basal expansion ratio at bloom | 2.5 | 3.57 → 1.00 (depending on O) | Matches only at initial state |
Floret spiral arm count | Consecutive Fibonacci pairs | 13 (one family only) | Partial match |
Tone-mapping coefficients | a 2.51 · b 0.03 · c 2.43 · d 0.59 · e 0.14 | Identical | Match |
Sphere tracing distance lower bound |
| Violated samples: 0.29% | Approximation |
- Item
Ratio of ray-cast flower petals to floret radius
- Literature value
0.667 – 0.706
- This implementation
0.687
- Verdict
Match
- Item
Ray-cast petal count
- Literature value
Typically 13 – 34+
- This implementation
28 / 30 / 32 / 34
- Verdict
Upper bound of range, always even
- Item
Floret/corolla diameter
- Literature value
34 – 60 mm
- This implementation
0.805 m
- Verdict
Approximately 17× (intentional scale)
- Item
Distal/basal expansion ratio at bloom
- Literature value
2.5
- This implementation
3.57 → 1.00 (depending on O)
- Verdict
Matches only at initial state
- Item
Floret spiral arm count
- Literature value
Consecutive Fibonacci pairs
- This implementation
13 (one family only)
- Verdict
Partial match
- Item
Tone-mapping coefficients
- Literature value
a 2.51 · b 0.03 · c 2.43 · d 0.59 · e 0.14
- This implementation
Identical
- Verdict
Match
- Item
Sphere tracing distance lower bound
- Literature value
|f(x)| ≤ d(x, f⁻¹(0))- This implementation
Violated samples: 0.29%
- Verdict
Approximation
1. Bloom dynamics — Liang & Mahadevan (2011)
This was the reference paper on where the force that opens a flower comes from.
Through surgical manipulation and quantitative measurement in Lilium casablanca, it demonstrated that petal margin growth, not the widely accepted midrib growth or adaxial/abaxial differential growth, drives blooming.
Measured values: the growth strain of the midrib is relatively uniform at about 10% for both petals and sepals, whereas the longitudinal growth strain at the margin exceeds 20% at the base and increases to nearly 50% toward the distal end, giving a distal-to-base ratio of approximately 2.5.
This implementation does not solve for stress. Instead, along a single cubic Bézier skeleton, it produces a gradient in the same direction by having the tip lead the shoulder, and the shoulder lead the hinge.
float release = smootherStep(opening);
float tipRelease = release;
float shoulderRelease = release * (0.58 + 0.42 * opening);
float hingeRelease = release * (0.28 + 0.72 * opening);
| tip | shoulder | hinge | tip/hinge |
|---|---|---|---|---|
0.00 | 1.00 | 0.580 | 0.280 | 3.57 |
0.17 | 1.00 | 0.651 | 0.402 | 2.49 |
0.50 | 1.00 | 0.790 | 0.640 | 1.56 |
1.00 | 1.00 | 1.000 | 1.000 | 1.00 |
- O
0.00
- tip
1.00
- shoulder
0.580
- hinge
0.280
- tip/hinge
3.57
- O
0.17
- tip
1.00
- shoulder
0.651
- hinge
0.402
- tip/hinge
2.49
- O
0.50
- tip
1.00
- shoulder
0.790
- hinge
0.640
- tip/hinge
1.56
- O
1.00
- tip
1.00
- shoulder
1.000
- hinge
1.000
- tip/hinge
1.00
The sign and magnitude are correct, but the time-dependency differs. In real lilies, the distal/proximal slope ratio is maintained throughout blooming, whereas in this implementation the slope passes through the literature value of 2.5 near O=0.17 and then continues to decrease, reaching 1.00 at full bloom, meaning the slope has vanished. This is a consequence of starting all three control points simultaneously so that curvature wrinkles do not appear in the bud, and it is a visual choice rather than a value tuned to measurements.
2. Morphology - Flora of North America, Leucanthemum vulgare
The oxeye daisy is the closest real-world counterpart. Values from the FNA description:
Involucre diameter 12–20+ mm
Ray florets usually 13–34+, rarely 0
Laminae length 12–20(–35+) mm
Disc diameter 10–20 mm
Corresponding values in this implementation (random mean, fully bloomed):
baseRadius = 0.0261, reach = 0.3763 → petal tip radius 0.4024
coreRadius.x (bloom) = 0.126 → disc floret radiusLiterature | This implementation | |
|---|---|---|
Lamina / capitulum radius | 0.667 – 0.706 | 0.687 |
Capitulum diameter | 34 – 60 mm | 805 mm |
Ray floret count | 13 – 34+ | 28 / 30 / 32 / 34 |
- #1
Lamina / capitulum radius
- Literature
0.667 – 0.706
- This implementation
0.687
- #1
Capitulum diameter
- Literature
34 – 60 mm
- This implementation
805 mm
- #1
Ray floret count
- Literature
13 – 34+
- This implementation
28 / 30 / 32 / 34
The proportions are correct, but the absolute scale is approximately 17× larger. This is intentional: the premise of the demo is passing through human-sized flowers inside a cavern with a radius of 2.28 m, so the absolute scale is by design.
The ray-cast petal count falls within the documented range, though the formula 28 + 2⌊4ξ⌋ makes it always even and places it in only the top four values of the 13–34+ range. There is no morphological basis for the even-only constraint; the upper-end bias is a visual choice, since fewer petals look sparse at this scale. (It is notable that the FNA lists the range as 13–34, with both endpoints being Fibonacci numbers, but no sources were found confirming that ray-cast petal counts cluster at Fibonacci numbers. The Fibonacci bias that is documented in the literature applies to the spiral counts of the floret disk below.)
A personal note: I considered making the flowers more varied but held back out of concern that it would introduce problems. The implementation turned out to be simpler than expected. Without references to draw on, and given how many attempts the actual implementation challenges required, it was not easy to push further.
3. Floret Disk Arrangement — Vogel (1979)
This is the standard model for describing the arrangement of disk florets at the center of a capitulum.
θ_n = n · 137.50776° (golden angle)
r_n = c √n (constant area per floret)Rather than placing individual point sets, this implementation draws the spiral pattern as a texture.
float phyllotaxis = 0.5 + 0.5 * cos(
76.0 * coreRadiusNormalized - 13.0 * coreAngle
);
float diskRings = 0.5 + 0.5 * cos(54.0 * coreRadiusNormalized + 2.0 * sin(coreAngle * 5.0));In Asteraceae capitula, two families of spirals winding in opposite directions (parastichy) are visible simultaneously, and their counts are typically consecutive Fibonacci pairs: 34/55 for gerbera, 55/89 for sunflower. Exceptions have been reported as well, including cases where only one direction is distinct or the spirals are uncountable.
The 13 in the angular term is a Fibonacci number, placing it within that sequence. The values 76 and 54 in the radial term, however, do not correspond to any Fibonacci pair, and more importantly, there is only one spiral family. Because this implementation draws a pattern that winds in only one direction, the intersecting spirals characteristic of a real floret disk do not appear up close. The actual pair count appropriate to the capitulum size in this demo was not verified.
4. Sphere Tracing Prerequisites — Hart (1996)
The problem of "steps skipping over petals" was a violation of the definition set out in this paper.
Definition 2. A function
f: R³ → Ris a signed distance bound of its implicit surfacef⁻¹(0)if and only if|f(x)| ≤ d(x, f⁻¹(0)). (7)Theorem 1. Let
fbe Lipschitz with Lipschitz boundλ ≥ Lip f. Thenf/λis a signed distance bound of its implicit surface.
macroDistance does not satisfy equation (7). Measured results: before fixing the wall term, 8.06% of samples contained geometry within the claimed radius; after the fix, 0.29% do.
What, then, is the role of the marching factor 0.60? In light of Theorem 1, it is an empirically chosen Lipschitz upper bound λ ≈ 1/0.60 = 1.67. That is, the implementation follows exactly the procedure of dividing f by λ to convert it into a distance lower bound, but determines λ by measurement rather than by derivation. The remaining 0.29% exists for the same reason: λ is not the true Lipschitz constant.
5. Volume Integration — Max (1995)
The front-to-back accumulation model with absorption and emission corresponds to the absorption plus emission category in Max's taxonomy. The implementation's T_step = exp(−σ_t Δs) and L ← L + T·S·α are its discretization. Scattering is approximated by a single directional derivative and a combined forward-and-backward phase approximation; multiple scattering is not solved.
6. Rendering Constants
Constants | References | This implementation |
|---|---|---|
| Jimenez (2014), interleaved gradient noise |
|
| Narkowicz (2016), ACES filmic curve approximation |
|
- Constants
52.9829189,0.06711056,0.00583715- References
Jimenez (2014), interleaved gradient noise
- This implementation
interleavedGradientNoise()as-is
- Constants
a 2.51 · b 0.03 · c 2.43 · d 0.59 · e 0.14- References
Narkowicz (2016), ACES filmic curve approximation
- This implementation
acesToneMap()as-is
Values without a basis yet
A real-world reference for petal sheet thickness could not be found, so it was set arbitrarily. The sheet in this implementation has a FWHM of 0.0099, and for a petal length of 0.3763 the length/thickness ratio = 38. No source documenting the actual thickness of Asteraceae ray floret ligules could be verified, so it is impossible to judge whether this ratio is correct. Because thickness feeds into both the optics (transmittance) and the marching (minimum step size), if a source is found, sheetThickness and MIN_VOLUME_STEP should be reviewed together.
References
Growth, geometry, and mechanics of a blooming lily
Liang, H. & Mahadevan, L. (2011). PNAS 108(14), 5516–5521. https://doi.org/10.1073/pnas.1007808108
Leucanthemum vulgare Lamarck
Flora of North America Editorial Committee. FNA vol. 19. http://floranorthamerica.org/Leucanthemum_vulgare
A better way to construct the sunflower head
Vogel, H. (1979). Mathematical Biosciences 44(3–4), 179–189.
My favourite flowering image: a capitulum of Asteraceae
(Parastichy pairs and their exceptions in the capitulum of Asteraceae) Journal of Experimental Botany 70(21), e6496. https://academic.oup.com/jxb/article/70/21/e6496/2964617
Computer Graphics & Rendering
Sphere tracing: a geometric method for the antialiased ray tracing of implicit surfaces
Hart, J. C. (1996). The Visual Computer 12(10), 527–545.
Optical models for direct volume rendering
Max, N. (1995). IEEE Transactions on Visualization and Computer Graphics 1(2), 99–108.
Next generation post processing in Call of Duty: Advanced Warfare
Jimenez, J. (2014). SIGGRAPH Advances in Real-Time Rendering in Games. https://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare/
ACES filmic tone mapping curve
Narkowicz, K. (2016). https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/