diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 014353fc..24038dcb 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -219,28 +219,71 @@ fn cylinder(ctx: &EffectCtx, mut b: Buf) -> Buf { let (_, _, w, h) = rect; let r = (ctx.params.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5); let c = b.to_px(ctx.params.v2("position")); + // Keep the pre-existing Rotation control as an additional texture spin. let spin = ctx.params.f("rotation").to_radians(); + let (rx, ry, rz) = (ctx.params.f("rotationX"), ctx.params.f("rotationY"), ctx.params.f("rotationZ")); + let tilted = rx != 0.0 || ry != 0.0 || rz != 0.0; + let m = rot_xyz(rx, ry, rz); + // The cylinder's local Y axis after rotation, in viewer coordinates. + let axis = [m[0][1], m[1][1], m[2][1]]; + let a = 1.0 - axis[2] * axis[2]; let mode = ctx.params.e("render"); let sh = Shading::from(ctx); let src = b.img.clone(); b.img.rows_mut().for_each(|(y, row)| { - let v = (y as f64 + 0.5 - c.1) / h + 0.5; + let dy = y as f64 + 0.5 - c.1; for (x, px) in row.iter_mut().enumerate() { - let s = (x as f64 + 0.5 - c.0) / r; - if s.abs() >= 1.0 { + let dx = x as f64 + 0.5 - c.0; + if !tilted { + // Preserve exactly the mapping of projects saved before axis rotations. + let v = dy / h + 0.5; + let s = dx / r; + if s.abs() >= 1.0 { + *px = [0.0; 4]; + continue; + } + let th = s.asin(); + let cz = th.cos(); + let front = || sh.shade(texel(&src, rect, (th + spin) / std::f64::consts::TAU + 0.5, v), [s, 0.0, cz]); + let back = || sh.shade(texel(&src, rect, (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5, v), [-s, 0.0, cz]); + *px = match mode { + 1 => front(), + 2 => back(), + _ => over(front(), back()), + }; + continue; + } + // Intersect the viewing ray (dx,dy,z) with the rotated cylindrical + // side wall: |q - dot(q,axis)*axis|^2 = radius^2. + let da = dx * axis[0] + dy * axis[1]; + let bq = -da * axis[2]; + let discriminant = bq * bq - a * (dx * dx + dy * dy - da * da - r * r); + if a <= 1e-10 || !discriminant.is_finite() || discriminant <= 0.0 { *px = [0.0; 4]; continue; } - let th = s.asin(); - let cz = th.cos(); - let front = || { - let u = (th + spin) / std::f64::consts::TAU + 0.5; - sh.shade(texel(&src, rect, u, v), [s, 0.0, cz]) - }; - let back = || { - let u = (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5; - sh.shade(texel(&src, rect, u, v), [-s, 0.0, cz]) + let root = discriminant.sqrt(); + let face = |z: f64, back: bool| { + // Rᵀ turns the intersection into cylinder-local coordinates. + let local = [ + m[0][0] * dx + m[1][0] * dy + m[2][0] * z, + m[0][1] * dx + m[1][1] * dy + m[2][1] * z, + m[0][2] * dx + m[1][2] * dy + m[2][2] * z, + ]; + let u = (local[0].atan2(local[2]) + spin) / std::f64::consts::TAU + 0.5; + let v = local[1] / h + 0.5; + let sign = if back { -1.0 } else { 1.0 }; + let nx = local[0] / r * sign; + let nz = local[2] / r * sign; + let normal = [ + m[0][0] * nx + m[0][2] * nz, + m[1][0] * nx + m[1][2] * nz, + m[2][0] * nx + m[2][2] * nz, + ]; + sh.shade(texel(&src, rect, u, v), normal) }; + let front = || face((-bq + root) / a, false); + let back = || face((-bq - root) / a, true); *px = match mode { 1 => front(), 2 => back(), @@ -362,6 +405,9 @@ pub fn specs() -> Vec { p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), p("position", "Position", pt(0.5, 0.5), ParamUi::Point), p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("rotationX", "Rotation X", num(0.0), ParamUi::Angle), + p("rotationY", "Rotation Y", num(0.0), ParamUi::Angle), + p("rotationZ", "Rotation Z", num(0.0), ParamUi::Angle), p("render", "Render", Value::Enum(0), render()), ]; cyl.extend(shading()); @@ -527,6 +573,31 @@ mod tests { assert_eq!(out.get(50 + 18, 10)[3], 0.0); } + #[test] + fn cylinder_axis_rotations_affect_geometry_and_texture() { + let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]); + let default = run("ec.perspective.cccylinder", &img, &[]).img; + let x_tilt = run("ec.perspective.cccylinder", &img, &[("rotationX", num(70.0))]).img; + // Tilting the finite cylinder shortens its vertical projection. + assert!(default.get(50, 10)[3] > 0.99); + assert_eq!(x_tilt.get(50, 10)[3], 0.0); + assert!(x_tilt.get(50, 50)[3] > 0.99); + let z_roll = run("ec.perspective.cccylinder", &img, &[("rotationZ", num(90.0))]).img; + // A quarter turn moves the side wall from vertical to horizontal. + assert_eq!(default.get(5, 50)[3], 0.0); + assert!(z_roll.get(5, 50)[3] > 0.99); + + let mut striped = Image::filled(100, 100, [0.0, 0.0, 0.0, 1.0]); + for y in 0..100 { + for x in 0..100 { + striped.set(x, y, [x as f32 / 100.0, 0.2, 0.3, 1.0]); + } + } + let baseline = run("ec.perspective.cccylinder", &striped, &[]).img; + let spun = run("ec.perspective.cccylinder", &striped, &[("rotationY", num(90.0))]).img; + assert!((baseline.get(50, 50)[0] - spun.get(50, 50)[0]).abs() > 0.05); + } + #[test] fn spotlight_darkens_outside_cone() { let img = Image::filled(80, 80, [1.0, 1.0, 1.0, 1.0]); diff --git a/crates/gpu/src/fx_transition.rs b/crates/gpu/src/fx_transition.rs index b74607a0..a0ad5465 100644 --- a/crates/gpu/src/fx_transition.rs +++ b/crates/gpu/src/fx_transition.rs @@ -448,6 +448,13 @@ fn cylinder(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.u[0][0] = pr.e("render"); p.f[0] = f4([c.0, c.1, r, h]); p.f[2][0] = pr.f("rotation").to_radians() as f32; + let (rx, ry, rz) = (pr.f("rotationX"), pr.f("rotationY"), pr.f("rotationZ")); + p.u[0][1] = (rx != 0.0 || ry != 0.0 || rz != 0.0) as u32; + let m = rot_xyz(rx, ry, rz); + // Matrix rows, as for CC Sphere; the kernel uses Rᵀ for mapping. + for (i, row) in m.iter().enumerate() { + p.f[7 + i] = f4([row[0], row[1], row[2], 0.0]); + } run(e, "ftr_cylinder", &p, b, None, None) } diff --git a/crates/gpu/src/shaders/fx_transition.wgsl b/crates/gpu/src/shaders/fx_transition.wgsl index 66278a68..379b6446 100644 --- a/crates/gpu/src/shaders/fx_transition.wgsl +++ b/crates/gpu/src/shaders/fx_transition.wgsl @@ -666,7 +666,24 @@ fn ftr_faces(mode: u32, front: vec4, back: vec4) -> vec4 { return ftr_over(front, back); } -// Cylinder: u[0].x = render; f[0] = (cx, cy, radius, layer height); f[2].x = rotation. +// Cylinder: u[0].x = render, u[0].y = axis rotation enabled; +// f[0] = (cx, cy, radius, layer height); f[2].x = legacy texture spin; +// f[7..10] = rows of the local-to-view rotation matrix. +fn ftr_cylinder_face(d: vec2, z: f32, back: bool) -> vec4 { + let m0 = P.f[7].xyz; + let m1 = P.f[8].xyz; + let m2 = P.f[9].xyz; + let q = vec3(d, z); + let local = vec3(m0.x * q.x + m1.x * q.y + m2.x * q.z, + m0.y * q.x + m1.y * q.y + m2.y * q.z, + m0.z * q.x + m1.z * q.y + m2.z * q.z); + let u = (atan2(local.x, local.z) + P.f[2].x) / 6.283185307179586 + 0.5; + let v = local.y / P.f[0].w + 0.5; + let flip = select(1.0, -1.0, back); + let n = vec3(local.x / P.f[0].z * flip, 0.0, local.z / P.f[0].z * flip); + return ftr_shade(ftr_texel(u, v), vec3(dot(m0, n), dot(m1, n), dot(m2, n))); +} + @compute @workgroup_size(16, 16) fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3) { let p = vec2(gid.xy); @@ -674,18 +691,35 @@ fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3) { return; } let d = ftr_centre(p) - P.f[0].xy; - let v = d.y / P.f[0].w + 0.5; - let s = d.x / P.f[0].z; - if (abs(s) >= 1.0) { + if (P.u[0].y == 0u) { + // Original path is unchanged for old projects and default parameters. + let v = d.y / P.f[0].w + 0.5; + let s = d.x / P.f[0].z; + if (abs(s) >= 1.0) { + textureStore(out, p, vec4(0.0)); + return; + } + let tau = 6.283185307179586; + let th = asin_p(s); + let cz = cos(th); + let spin = P.f[2].x; + let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3(s, 0.0, cz)); + let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3(-s, 0.0, cz)); + textureStore(out, p, ftr_faces(P.u[0].x, front, back)); + return; + } + let axis = vec3(P.f[7].y, P.f[8].y, P.f[9].y); + let a = 1.0 - axis.z * axis.z; + let da = dot(d, axis.xy); + let bq = -da * axis.z; + let disc = bq * bq - a * (dot(d, d) - da * da - P.f[0].z * P.f[0].z); + if (a <= 1e-10 || !(disc > 0.0)) { textureStore(out, p, vec4(0.0)); return; } - let tau = 6.283185307179586; - let th = asin_p(s); - let cz = cos(th); - let spin = P.f[2].x; - let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3(s, 0.0, cz)); - let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3(-s, 0.0, cz)); + let root = sqrt(disc); + let front = ftr_cylinder_face(d, (-bq + root) / a, false); + let back = ftr_cylinder_face(d, (-bq - root) / a, true); textureStore(out, p, ftr_faces(P.u[0].x, front, back)); } diff --git a/crates/gpu/src/tests_fx_transition.rs b/crates/gpu/src/tests_fx_transition.rs index ad6ebdf5..b4b3572a 100644 --- a/crates/gpu/src/tests_fx_transition.rs +++ b/crates/gpu/src/tests_fx_transition.rs @@ -89,6 +89,11 @@ fn perspective() { effect_case("ec.perspective.cccylinder", &[("position", pt(35.0, 22.0)), ("rotation", n(30.0))]); effect_case("ec.perspective.cccylinder", &[("radius", n(60.0)), ("render", e(1)), ("lightDirection", n(70.0)), ("roughness", n(0.2))]); effect_case("ec.perspective.cccylinder", &[("render", e(2)), ("lightHeight", n(20.0)), ("specular", n(60.0))]); + // All three axes: independently and combined, against the CPU reference. + effect_case("ec.perspective.cccylinder", &[("rotationX", n(35.0)), ("render", e(1))]); + effect_case("ec.perspective.cccylinder", &[("rotationY", n(65.0)), ("rotation", n(15.0))]); + effect_case("ec.perspective.cccylinder", &[("rotationZ", n(-25.0)), ("render", e(2))]); + effect_case("ec.perspective.cccylinder", &[("rotationX", n(30.0)), ("rotationY", n(20.0)), ("rotationZ", n(15.0))]); effect_case("ec.perspective.ccsphere", &[("offset", pt(35.0, 22.0)), ("radius", n(18.0)), ("rotationX", n(20.0)), ("rotationY", n(35.0))]); effect_case("ec.perspective.ccsphere", &[("radius", n(20.0)), ("rotationZ", n(50.0)), ("render", e(2))]); effect_case("ec.perspective.ccspotlight", &[("from", pt(15.0, 8.0)), ("to", pt(35.0, 22.0))]); diff --git a/docs/effects.md b/docs/effects.md index e498cf1a..0fac41ed 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -306,7 +306,7 @@ Effects are addressed by id (`ec..`) or display name in commands | 3D Glasses | `ec.perspective.3dglasses` | 8 | GPU | 32 | Implemented | | Bevel Alpha | `ec.perspective.bevelalpha` | 4 | GPU | 32 | Implemented | | Bevel Edges | `ec.perspective.beveledges` | 4 | GPU | 32 | Implemented | -| CC Cylinder | `ec.perspective.cccylinder` | 12 | GPU | 32 | Implemented | +| CC Cylinder | `ec.perspective.cccylinder` | 15 | GPU | 32 | Implemented | | CC Environment | `ec.perspective.ccenvironment` | 4 | GPU | 32 | Implemented | | CC Sphere | `ec.perspective.ccsphere` | 14 | GPU | 32 | Implemented | | CC Spotlight | `ec.perspective.ccspotlight` | 8 | GPU | 32 | Implemented |