Skip to content

[Audit][High] DDA voxel traversal returns stale face on simultaneous axis hits #601

@MichaelFisher1997

Description

@MichaelFisher1997

🔍 Module Scanned

modules/engine-math/src/ (automated audit scan)

📝 Summary

The castThroughVoxels DDA algorithm in ray.zig has a bug where the last_face is not updated when the ray hits two or three voxel boundaries simultaneously (within floating-point epsilon). This causes the function to return the face from the previous step instead of the correct face for the hit voxel.

📍 Location

  • File: modules/engine-math/src/ray.zig:199-214
  • Function/Scope: castThroughVoxels (lines 132-218)

🔴 Severity: High

  • High: Incorrect block selection in raycasts, wrong face normal for block interactions, broken block placement/destruction in game

💥 Impact

When a player points at a block corner/edge (ray passing exactly through a voxel boundary), the game will report the wrong face as the hit face. This causes:

  • Block placement on wrong face (placing blocks where player isn't looking)
  • Block breaking targets wrong face
  • Raycast hit info is inconsistent with actual traversal

🔎 Evidence

// Lines 199-214: The problem is that last_face is NOT updated when t_max_x == t_max_y or t_max_y == t_max_z
if (t_max_x <= t_max_y and t_max_x <= t_max_z) {
    x += step_x;
    distance = t_max_x;
    t_max_x += t_delta_x;
    // BUG: last_face is NOT set here when t_max_x == t_max_y or t_max_x == t_max_z
    last_face = if (step_x > 0) .west else .east;  // Only set in this branch
} else if (t_max_y <= t_max_z) {
    y += step_y;
    distance = t_max_y;
    t_max_y += t_delta_y;
    last_face = if (step_y > 0) .bottom else .top;
} else {
    z += step_z;
    distance = t_max_z;
    t_max_z += t_delta_z;
    last_face = if (step_z > 0) .north else .south;
}

When t_max_x <= t_max_y and t_max_x <= t_max_z is true, last_face is set. However, when t_max_x == t_max_y or t_max_x == t_max_z, the ray is hitting multiple boundaries simultaneously and the face should reflect ALL axes that moved. The condition t_max_x <= t_max_y and t_max_x <= t_max_z is still true in this case, so we take this branch, but we don't update last_face when we also moved in another axis.

🛠️ Proposed Fix

When axes tie, last_face should be set based on all axes that stepped, not just the first one in the priority order. For example, when stepping in X and Y simultaneously (t_max_x == t_max_y < t_max_z), last_face should be determined by which axis had the larger step direction or by a tie-breaking rule.

The fix should be:

  1. After determining which axes stepped, update last_face to reflect the actual face where the hit occurred
  2. When multiple axes tie, last_face should represent the most significant face of the hit
// Example fix approach:
if (t_max_x <= t_max_y and t_max_x <= t_max_z) {
    x += step_x;
    distance = t_max_x;
    t_max_x += t_delta_x;
    // Determine face BEFORE potentially stepping in other axes
    if (t_max_y <= t_max_z and @abs(t_max_x - t_max_y) < 1e-8) {
        // Also stepping in Y - face depends on combined movement
        last_face = if (step_y > 0) .bottom else .top;
    } else {
        last_face = if (step_x > 0) .west else .east;
    }
}

Alternatively, the face could be computed at the end based on the direction that caused the hit, taking into account that multiple axes may have moved simultaneously.

✅ Acceptance Criteria

  • Test case added for ray hitting voxel at corner (t_max_x == t_max_y == t_max_z scenario)
  • Test case added for ray hitting voxel edge (t_max_x == t_max_y < t_max_z scenario)
  • castThroughVoxels returns correct face when ray passes through voxel boundaries
  • All existing tests in modules/engine-math/src/ray.zig continue to pass
  • nix develop --command zig build test passes

📚 References

Metadata

Metadata

Assignees

No one assigned

    Labels

    automated-auditIssues found by automated opencode audit scansbugSomething isn't workingquestionFurther information is requested

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions