Skip to main content

siglus_scene_vm/
render_math.rs

1use crate::layer::Sprite;
2
3#[derive(Debug, Clone, Copy)]
4pub struct ProjectedPoint {
5    pub x: f32,
6    pub y: f32,
7    pub depth: f32,
8    /// Homogeneous clip-space W. 2D paths use 1.0; the 3D camera path uses
9    /// camera-space Z, matching D3DX perspective projection. The renderer
10    /// keeps this value so UV/world varyings remain perspective-correct even
11    /// though the quad's screen position is prepared on the CPU.
12    pub clip_w: f32,
13}
14
15#[derive(Debug, Clone, Copy)]
16pub struct SpriteQuadGeometry {
17    pub projected: [ProjectedPoint; 4],
18    pub world: [[f32; 3]; 4],
19}
20
21#[derive(Debug, Clone, Copy)]
22struct Vec3 {
23    x: f32,
24    y: f32,
25    z: f32,
26}
27
28impl Vec3 {
29    fn new(x: f32, y: f32, z: f32) -> Self {
30        Self { x, y, z }
31    }
32
33    fn sub(self, rhs: Self) -> Self {
34        Self::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
35    }
36
37    fn add(self, rhs: Self) -> Self {
38        Self::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
39    }
40
41    fn dot(self, rhs: Self) -> f32 {
42        self.x * rhs.x + self.y * rhs.y + self.z * rhs.z
43    }
44
45    fn cross(self, rhs: Self) -> Self {
46        Self::new(
47            self.y * rhs.z - self.z * rhs.y,
48            self.z * rhs.x - self.x * rhs.z,
49            self.x * rhs.y - self.y * rhs.x,
50        )
51    }
52
53    fn normalize(self) -> Self {
54        let len2 = self.dot(self);
55        if len2 <= f32::EPSILON {
56            return Self::new(0.0, 0.0, 0.0);
57        }
58        let inv = len2.sqrt().recip();
59        Self::new(self.x * inv, self.y * inv, self.z * inv)
60    }
61}
62
63fn rotate_x(v: Vec3, angle: f32) -> Vec3 {
64    let (s, c) = angle.sin_cos();
65    Vec3::new(v.x, v.y * c - v.z * s, v.y * s + v.z * c)
66}
67
68fn rotate_y(v: Vec3, angle: f32) -> Vec3 {
69    let (s, c) = angle.sin_cos();
70    Vec3::new(v.x * c + v.z * s, v.y, -v.x * s + v.z * c)
71}
72
73fn rotate_z(v: Vec3, angle: f32) -> Vec3 {
74    let (s, c) = angle.sin_cos();
75    Vec3::new(v.x * c - v.y * s, v.x * s + v.y * c, v.z)
76}
77
78fn uses_3d(sprite: &Sprite) -> bool {
79    sprite.billboard
80        || sprite.camera_enabled
81        || sprite.z.abs() > f32::EPSILON
82        || sprite.pivot_z.abs() > f32::EPSILON
83        || (sprite.scale_z - 1.0).abs() > 1e-6
84        || sprite.rotate_x.abs() > f32::EPSILON
85        || sprite.rotate_y.abs() > f32::EPSILON
86}
87
88fn transform_local_point(sprite: &Sprite, px: f32, py: f32, dst_x: f32, dst_y: f32) -> Vec3 {
89    let (anchor_x, anchor_y, mut p) = if sprite.object_anchor {
90        (
91            dst_x,
92            dst_y,
93            Vec3::new(
94                px - sprite.texture_center_x - sprite.pivot_x,
95                py - sprite.texture_center_y - sprite.pivot_y,
96                -sprite.pivot_z,
97            ),
98        )
99    } else {
100        (
101            dst_x + sprite.pivot_x,
102            dst_y + sprite.pivot_y,
103            Vec3::new(px - sprite.pivot_x, py - sprite.pivot_y, -sprite.pivot_z),
104        )
105    };
106    p.x *= sprite.scale_x;
107    p.y *= sprite.scale_y;
108    p.z *= sprite.scale_z;
109    // D3DXMatrixRotationYawPitchRoll(yaw, pitch, roll) applies roll,
110    // then pitch, then yaw to row vectors.
111    p = rotate_z(p, sprite.rotate);
112    p = rotate_x(p, sprite.rotate_x);
113    p = rotate_y(p, sprite.rotate_y);
114    p.add(Vec3::new(anchor_x, anchor_y, sprite.z + sprite.pivot_z))
115}
116
117fn camera_basis(sprite: &Sprite) -> (Vec3, Vec3, Vec3, Vec3) {
118    let eye = Vec3::new(
119        sprite.camera_eye[0],
120        sprite.camera_eye[1],
121        sprite.camera_eye[2],
122    );
123    let target = Vec3::new(
124        sprite.camera_target[0],
125        sprite.camera_target[1],
126        sprite.camera_target[2],
127    );
128    let up = Vec3::new(
129        sprite.camera_up[0],
130        sprite.camera_up[1],
131        sprite.camera_up[2],
132    );
133    let forward = target.sub(eye).normalize();
134    let right = up.cross(forward).normalize();
135    let up2 = forward.cross(right).normalize();
136    (eye, forward, right, up2)
137}
138
139fn transform_billboard_point(sprite: &Sprite, px: f32, py: f32, dst_x: f32, dst_y: f32) -> Vec3 {
140    let (_, _, right, up) = camera_basis(sprite);
141    let lx = (px - sprite.pivot_x) * sprite.scale_x;
142    // Texture-space Y grows downward, while WORLD Y grows upward.
143    let ly = -(py - sprite.pivot_y) * sprite.scale_y;
144    let (s, c) = sprite.rotate.sin_cos();
145    let rx = lx * c - ly * s;
146    let ry = lx * s + ly * c;
147    let anchor = Vec3::new(
148        dst_x + sprite.pivot_x,
149        dst_y + sprite.pivot_y,
150        sprite.z + sprite.pivot_z,
151    );
152    anchor.add(Vec3::new(
153        right.x * rx + up.x * ry,
154        right.y * rx + up.y * ry,
155        right.z * rx + up.z * ry,
156    ))
157}
158
159fn project_point(sprite: &Sprite, p: Vec3, win_w: f32, win_h: f32) -> Option<ProjectedPoint> {
160    if !sprite.camera_enabled {
161        let depth = (0.5 - p.z / 100000.0).clamp(0.0, 1.0);
162        return Some(ProjectedPoint {
163            x: p.x,
164            y: p.y,
165            depth,
166            clip_w: 1.0,
167        });
168    }
169
170    let (eye, zaxis, xaxis, yaxis) = camera_basis(sprite);
171
172    let rel = p.sub(eye);
173    let cx = rel.dot(xaxis);
174    let cy = rel.dot(yaxis);
175    let cz = rel.dot(zaxis);
176    if cz <= 1e-3 {
177        return None;
178    }
179
180    let aspect = if win_h.abs() > f32::EPSILON {
181        win_w / win_h
182    } else {
183        1.0
184    };
185    let vfov = sprite
186        .camera_view_angle_deg
187        .to_radians()
188        .clamp(1e-3, std::f32::consts::PI - 1e-3);
189    let tan_half_v = (vfov * 0.5).tan().max(1e-3);
190    let tan_half_h = (tan_half_v * aspect.max(1e-3)).max(1e-3);
191
192    let x_ndc = cx / (cz * tan_half_h);
193    let y_ndc = cy / (cz * tan_half_v);
194
195    let sx = (x_ndc + 1.0) * 0.5 * win_w;
196    let sy = (1.0 - y_ndc) * 0.5 * win_h;
197    let near = 1.0;
198    let far = 10000.0;
199    let depth = (far / (far - near) - near * far / ((far - near) * cz)).clamp(0.0, 1.0);
200    Some(ProjectedPoint {
201        x: sx,
202        y: sy,
203        depth,
204        clip_w: cz,
205    })
206}
207
208fn signed_area(a: (f32, f32), b: (f32, f32), c: (f32, f32)) -> f32 {
209    (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
210}
211
212pub fn sprite_quad_geometry_rect(
213    sprite: &Sprite,
214    dst_x: f32,
215    dst_y: f32,
216    local_left: f32,
217    local_top: f32,
218    local_right: f32,
219    local_bottom: f32,
220    win_w: f32,
221    win_h: f32,
222) -> Option<SpriteQuadGeometry> {
223    let xf = if sprite.billboard {
224        transform_billboard_point as fn(&Sprite, f32, f32, f32, f32) -> Vec3
225    } else {
226        transform_local_point as fn(&Sprite, f32, f32, f32, f32) -> Vec3
227    };
228
229    let w0 = xf(sprite, local_left, local_top, dst_x, dst_y);
230    let w1 = xf(sprite, local_right, local_top, dst_x, dst_y);
231    let w2 = xf(sprite, local_right, local_bottom, dst_x, dst_y);
232    let w3 = xf(sprite, local_left, local_bottom, dst_x, dst_y);
233    let world = [
234        [w0.x, w0.y, w0.z],
235        [w1.x, w1.y, w1.z],
236        [w2.x, w2.y, w2.z],
237        [w3.x, w3.y, w3.z],
238    ];
239
240    if !uses_3d(sprite) {
241        return Some(SpriteQuadGeometry {
242            projected: [
243                ProjectedPoint {
244                    x: w0.x,
245                    y: w0.y,
246                    depth: 0.0,
247                    clip_w: 1.0,
248                },
249                ProjectedPoint {
250                    x: w1.x,
251                    y: w1.y,
252                    depth: 0.0,
253                    clip_w: 1.0,
254                },
255                ProjectedPoint {
256                    x: w2.x,
257                    y: w2.y,
258                    depth: 0.0,
259                    clip_w: 1.0,
260                },
261                ProjectedPoint {
262                    x: w3.x,
263                    y: w3.y,
264                    depth: 0.0,
265                    clip_w: 1.0,
266                },
267            ],
268            world,
269        });
270    }
271
272    let p0 = project_point(sprite, w0, win_w, win_h)?;
273    let p1 = project_point(sprite, w1, win_w, win_h)?;
274    let p2 = project_point(sprite, w2, win_w, win_h)?;
275    let p3 = project_point(sprite, w3, win_w, win_h)?;
276
277    if sprite.culling && signed_area((p0.x, p0.y), (p1.x, p1.y), (p2.x, p2.y)) <= 0.0 {
278        return None;
279    }
280
281    Some(SpriteQuadGeometry {
282        projected: [p0, p1, p2, p3],
283        world,
284    })
285}
286
287pub fn sprite_quad_points_rect(
288    sprite: &Sprite,
289    dst_x: f32,
290    dst_y: f32,
291    local_left: f32,
292    local_top: f32,
293    local_right: f32,
294    local_bottom: f32,
295    win_w: f32,
296    win_h: f32,
297) -> Option<[ProjectedPoint; 4]> {
298    sprite_quad_geometry_rect(
299        sprite,
300        dst_x,
301        dst_y,
302        local_left,
303        local_top,
304        local_right,
305        local_bottom,
306        win_w,
307        win_h,
308    )
309    .map(|quad| quad.projected)
310}
311
312pub fn sprite_quad_points(
313    sprite: &Sprite,
314    dst_x: f32,
315    dst_y: f32,
316    dst_w: f32,
317    dst_h: f32,
318    win_w: f32,
319    win_h: f32,
320) -> Option<[ProjectedPoint; 4]> {
321    sprite_quad_points_rect(sprite, dst_x, dst_y, 0.0, 0.0, dst_w, dst_h, win_w, win_h)
322}
323
324pub fn project_model_point(
325    sprite: &Sprite,
326    local: [f32; 3],
327    anchor_x: f32,
328    anchor_y: f32,
329    win_w: f32,
330    win_h: f32,
331) -> Option<ProjectedPoint> {
332    let mut p = Vec3::new(
333        local[0] - sprite.pivot_x,
334        local[1] - sprite.pivot_y,
335        local[2] - sprite.pivot_z,
336    );
337    p.x *= sprite.scale_x;
338    p.y *= sprite.scale_y;
339    p.z *= sprite.scale_z;
340    p = rotate_z(p, sprite.rotate);
341    p = rotate_x(p, sprite.rotate_x);
342    p = rotate_y(p, sprite.rotate_y);
343    p = p.add(Vec3::new(
344        anchor_x + sprite.pivot_x,
345        anchor_y + sprite.pivot_y,
346        sprite.z + sprite.pivot_z,
347    ));
348    project_point(sprite, p, win_w, win_h)
349}
350
351#[cfg(test)]
352mod tests {
353    use super::{Vec3, project_point, rotate_x, rotate_y, rotate_z, sprite_quad_points};
354    use crate::layer::Sprite;
355
356    fn camera_sprite() -> Sprite {
357        Sprite {
358            camera_enabled: true,
359            camera_eye: [0.0, 0.0, 0.0],
360            camera_target: [0.0, 0.0, 1.0],
361            camera_up: [0.0, 1.0, 0.0],
362            camera_view_angle_deg: 60.0,
363            ..Default::default()
364        }
365    }
366
367    #[test]
368    fn yaw_pitch_roll_matches_d3dx_application_order() {
369        let quarter = std::f32::consts::FRAC_PI_2;
370        let mut point = Vec3::new(1.0, 2.0, 3.0);
371        point = rotate_z(point, quarter);
372        point = rotate_x(point, quarter);
373        point = rotate_y(point, quarter);
374        assert!((point.x - 1.0).abs() < 1e-5);
375        assert!((point.y + 3.0).abs() < 1e-5);
376        assert!((point.z - 2.0).abs() < 1e-5);
377    }
378
379    #[test]
380    fn billboard_texture_top_projects_above_its_bottom() {
381        let mut sprite = camera_sprite();
382        sprite.billboard = true;
383        sprite.z = 100.0;
384        let quad = sprite_quad_points(&sprite, 0.0, 0.0, 32.0, 64.0, 640.0, 480.0).unwrap();
385        assert!(quad[0].y < quad[3].y);
386    }
387
388    #[test]
389    fn world_view_angle_is_vertical_and_does_not_change_with_window_width() {
390        let sprite = camera_sprite();
391        let point = Vec3::new(0.0, 1.0, 10.0);
392        let narrow = project_point(&sprite, point, 640.0, 480.0).unwrap();
393        let wide = project_point(&sprite, point, 1280.0, 480.0).unwrap();
394        assert!((narrow.y - wide.y).abs() < 1e-4);
395    }
396
397    #[test]
398    fn world_depth_matches_d3d_lh_near_and_far_planes() {
399        let sprite = camera_sprite();
400        let near = project_point(&sprite, Vec3::new(0.0, 0.0, 1.0), 640.0, 480.0).unwrap();
401        let far = project_point(&sprite, Vec3::new(0.0, 0.0, 10000.0), 640.0, 480.0).unwrap();
402        assert!(near.depth.abs() < 1e-6);
403        assert!((far.depth - 1.0).abs() < 1e-6);
404    }
405}