From d15f9da6684522253d1216fe71d11f723ba02e22 Mon Sep 17 00:00:00 2001 From: GeneralProtectionFault Date: Thu, 10 Sep 2026 03:02:40 -0400 Subject: [PATCH 1/3] Transform point function works (full sprite) --- scene/2d/mode7_sprite_2d.cpp | 408 ++++++++++++++++++++--------------- scene/2d/mode7_sprite_2d.h | 23 ++ scene/2d/sprite_2d.h | 3 +- 3 files changed, 257 insertions(+), 177 deletions(-) diff --git a/scene/2d/mode7_sprite_2d.cpp b/scene/2d/mode7_sprite_2d.cpp index e1e0f65edb5..cadc0686386 100644 --- a/scene/2d/mode7_sprite_2d.cpp +++ b/scene/2d/mode7_sprite_2d.cpp @@ -245,201 +245,30 @@ void Mode7Sprite2D::_mode7_rebuild_material() { } void Mode7Sprite2D::_mode7_rebuild_scanline_texture() { - // (Vertical) Resolution for smooth per-scanline interpolation - mainly for modulate (color). - // Use a high power-of-2 height so nearest-neighbor sampling doesn't produce - // visible bands in alpha or color channels between adjacent overrides. const int interpolate_resolution = 1024; - - // The interpolation mode is a single node-level setting that applies uniformly - // to the whole override array for this pass. - Mode7Sprite2D::Mode7InterpolationMode interp_mode = mode7_interpolation; - int num_overrides = mode7_scanline_overrides.size(); - Ref first; - if (num_overrides > 0) { - first = mode7_scanline_overrides[0]; - } - - // Make sure we have more than 1 scanline object to interpolate between - bool has_projection_anchors = false; - Transform2D transform_top, transform_bottom; - Vector2 pivot_top, pivot_bottom; - Color modulate_top, modulate_bottom; - real_t scale_top = 1.0f, scale_bottom = 1.0f; - real_t rotation_top = 0.0f, rotation_bottom = 0.0f; - if (interp_mode == Mode7Sprite2D::INTERPOLATION_PROJECTION && num_overrides >= 2) { - // auto used here to hopefully inline/avoid heap allocation - // These just guard against invalid/null values and are reusable below - auto safe_transform = [&](int i) { Ref s = mode7_scanline_overrides[i]; return s.is_valid() ? s->get_transform() : Transform2D(); }; - auto safe_pivot = [&](int i) { Ref s = mode7_scanline_overrides[i]; return s.is_valid() ? s->get_pivot() : Vector2(0.5f, 0.5f); }; - auto safe_modulate = [&](int i) { Ref s = mode7_scanline_overrides[i]; return s.is_valid() ? s->get_modulate() : Color(1.0f, 1.0f, 1.0f, 1.0f); }; - - transform_top = safe_transform(0); - transform_bottom = safe_transform(num_overrides - 1); - pivot_top = safe_pivot(0); - pivot_bottom = safe_pivot(num_overrides - 1); - modulate_top = safe_modulate(0); - modulate_bottom = safe_modulate(num_overrides - 1); - - // User-facing scale (already inverted by get_scale()). - Vector2 s_top = transform_top.get_scale(); - Vector2 s_bot = transform_bottom.get_scale(); - scale_top = 1.0f / MAX(s_top.x, 0.0001f); - scale_bottom = 1.0f / MAX(s_bot.x, 0.0001f); - rotation_top = transform_top.get_rotation(); - rotation_bottom = transform_bottom.get_rotation(); - has_projection_anchors = true; - } - Ref img = Image::create_empty(3, interpolate_resolution, false, Image::FORMAT_RGBAF); for (int y = 0; y < interpolate_resolution; y++) { float uv_y = (y + 0.5f) / (float)interpolate_resolution; - Transform2D result_transform; - Vector2 pivot; - Color mod; if (num_overrides == 0) { - // Identity transform, centered pivot, white modulate. img->set_pixel(0, y, Color(1.0f, 0.0f, 0.0f, 1.0f)); img->set_pixel(1, y, Color(0.0f, 0.0f, 0.5f, 0.5f)); img->set_pixel(2, y, Color(1.0f, 1.0f, 1.0f, 1.0f)); continue; } - if (interp_mode == Mode7Sprite2D::INTERPOLATION_PROJECTION && has_projection_anchors) { - // Per-scanline inverse-depth interpolation - // - // In a true perspective projection of a flat plane, texture scale is - // inversely proportional to depth (S ~ 1/Z). Therefore the VALUE - // that varies linearly with screen height is 1/S, not S itself. - // We interpolate in inverse-scale space, then invert back to get - // the correct perspective-correct affine matrix for this scanline. - // - // first entry = top / horizon anchor (small scale, far depth) - // last entry = bottom / close anchor (large scale, near depth) - - real_t t = uv_y; - - // Global projection tuning (projection mode only): - // pixel_aspect remaps the vertical coordinate (NTSC non-square pixel - // compensation), gamma reshapes the curve, strength blends the result - // toward a flat image, aspect_ratio scales x relative to y. - - // Inverse-depth interpolation of scale. - real_t inv_s_top = 1.0f / MAX(scale_top, 0.0001f); - real_t inv_s_bot = 1.0f / MAX(scale_bottom, 0.0001f); - - // Pixel aspect: stretch/compress the vertical progression about the - // center of the depth ramp so both endpoints stay pinned (f(0)=0, - // f(1)=1) — the top and bottom scanlines must always show the first - // and last anchor scales. 1.0 = no-op. Values <1.0 pull the curve - // toward the far (top) anchor; values >1.0 toward the near (bottom) - // anchor. (Square-pixel / NTSC 8:7 ≈ 1.125.) - real_t t_pa = (t * mode7_projection_pixel_aspect) / (t * mode7_projection_pixel_aspect + (1.0f - t)); - - // Gamma: reshape the progression (1.0 = linear inverse, <1.0 softens - // the falloff, >1.0 sharpens it). Math::pow keeps real_t precision - // instead of narrowing to float as powf would in double builds. - real_t t_g = Math::pow(t_pa, mode7_projection_gamma); - - real_t inv_s_cur = inv_s_top + (inv_s_bot - inv_s_top) * t_g; - real_t S = 1.0f / MAX(inv_s_cur, 0.0001f); // Perspective-correct scale. - - // Rotation interpolates linearly with screen height. - real_t theta = rotation_top + (rotation_bottom - rotation_top) * t; - - // Strength: blend between a flat (uniform) transform and the full - // perspective result, without altering the curve shape itself. - if (mode7_projection_strength < 1.0f) { - real_t S_flat = (scale_top + scale_bottom) * 0.5f; - real_t theta_flat = (rotation_top + rotation_bottom) * 0.5f; - S = S_flat + (S - S_flat) * mode7_projection_strength; - theta = theta_flat + (theta - theta_flat) * mode7_projection_strength; - } - - // Horizontal/vertical asymmetry: scale x relative to y - // (1.0 = uniform Mode 7-like, 0.5 = x is half of y, >1.0 reversed). - real_t Sx = S * mode7_projection_aspect_ratio; - real_t Sy = S; - - real_t cos_t = Math::cos(theta); - real_t sin_t = Math::sin(theta); - - // Affine matrix: A=Sx*cos, B=-Sy*sin, C=Sx*sin, D=Sy*cos. - Vector2 col0(cos_t * Sx, -sin_t * Sy); - Vector2 col1(sin_t * Sx, cos_t * Sy); - - // Pivot interpolates linearly. - pivot = pivot_top.lerp(pivot_bottom, (real_t)t); - - // Modulate interpolates linearly between horizon and close anchors. - mod = modulate_top.lerp(modulate_bottom, (real_t)t); - - // Scroll offset correction - // - // Scaling around a fixed pivot with changing per-scanline scale - // causes the texture to warp unless the translation offset is also - // adjusted so that world-space coordinates at the screen center - // remain stable. We correct the raw offset by adding a depth- - // proportional shift. - - Vector2 off_raw = transform_top.columns[2].lerp(transform_bottom.columns[2], (real_t)t); - - // Perspective correction: the offset must be shifted in proportion - // to how much the actual scale deviates from a linear blend of the - // anchors. Uses the strength-blended uniform scale S (not Sx) so the - // correction stays zero at both anchors regardless of - // mode7_projection_aspect_ratio. - real_t s_linear = scale_top + (scale_bottom - scale_top) * t; - if (mode7_projection_strength < 1.0f) { - const real_t s_flat = (scale_top + scale_bottom) * 0.5f; - s_linear = s_flat + (s_linear - s_flat) * mode7_projection_strength; - } - real_t depth_factor = (s_linear > 0.001f) ? (S / s_linear - 1.0f) : 0.0f; - - off_raw += (transform_bottom.columns[2] - transform_top.columns[2]) * depth_factor; - - result_transform = Transform2D(col0, col1, off_raw); - } // if we're doing projection - else { // Lerp or no interpolation - float idx_f = (num_overrides == 1) ? 0.0f : uv_y * (num_overrides - 1); - int idx_lo = CLAMP((int)idx_f, 0, num_overrides - 1); - int idx_hi = CLAMP(idx_lo + 1, 0, num_overrides - 1); - float frac = idx_f - (float)idx_lo; - - Ref entry_lo = mode7_scanline_overrides[idx_lo]; - Transform2D xf_lo = entry_lo.is_valid() ? entry_lo->get_transform() : Transform2D(); - Vector2 pivot_lo = entry_lo.is_valid() ? entry_lo->get_pivot() : Vector2(0.5f, 0.5f); - Color modulate_lo = entry_lo.is_valid() ? entry_lo->get_modulate() : Color(1.0f, 1.0f, 1.0f, 1.0f); - bool do_lerp = (interp_mode == Mode7Sprite2D::INTERPOLATION_LERP); - - if (do_lerp && idx_hi != idx_lo && frac > 0.0f) { - Ref entry_hi = mode7_scanline_overrides[idx_hi]; - Transform2D xf_hi = entry_hi.is_valid() ? entry_hi->get_transform() : Transform2D(); - Vector2 pivot_hi = entry_hi.is_valid() ? entry_hi->get_pivot() : Vector2(0.5f, 0.5f); - Color modulate_hi = entry_hi.is_valid() ? entry_hi->get_modulate() : Color(1.0f, 1.0f, 1.0f, 1.0f); - result_transform = xf_lo.interpolate_with(xf_hi, frac); - pivot = pivot_lo.lerp(pivot_hi, frac); - mod = modulate_lo.lerp(modulate_hi, frac); - } else { - int idx_nearest = CLAMP((int)roundf(idx_f), 0, num_overrides - 1); - Ref entry_nearest = mode7_scanline_overrides[idx_nearest]; - result_transform = entry_nearest.is_valid() ? entry_nearest->get_transform() : Transform2D(); - pivot = entry_nearest.is_valid() ? entry_nearest->get_pivot() : Vector2(0.5f, 0.5f); - mod = entry_nearest.is_valid() ? entry_nearest->get_modulate() : Color(1.0f, 1.0f, 1.0f, 1.0f); - } - } + Transform2D result_transform; + Vector2 pivot; + Color mod; + _mode7_compute_scanline_data((real_t)uv_y, result_transform, pivot, mod); - // These set the pixels for the "row" we're currently on (y) - // Transform/scale/rotation img->set_pixel(0, y, Color(result_transform.columns[0].x, result_transform.columns[1].x, result_transform.columns[0].y, result_transform.columns[1].y)); - // Scroll offset / pivot point img->set_pixel(1, y, Color(result_transform.columns[2].x, result_transform.columns[2].y, pivot.x, pivot.y)); - // Per-scanline modulate (RGBA) img->set_pixel(2, y, mod); - } // end of for loop + } if (_mode7_scanline_tex.is_null() || _mode7_scanline_tex->get_height() != interpolate_resolution) { _mode7_scanline_tex = ImageTexture::create_from_image(img); @@ -448,6 +277,232 @@ void Mode7Sprite2D::_mode7_rebuild_scanline_texture() { } } + +void Mode7Sprite2D::_mode7_compute_scanline_data(real_t p_uv_y, Transform2D &r_transform, Vector2 &r_pivot, Color &r_modulate) const { + Mode7Sprite2D::Mode7InterpolationMode interp_mode = mode7_interpolation; + int num_overrides = mode7_scanline_overrides.size(); + + if (num_overrides == 0) { + r_transform = Transform2D(); + r_pivot = Vector2(0.5f, 0.5f); + r_modulate = Color(1.0f, 1.0f, 1.0f, 1.0f); + return; + } + + if (interp_mode == Mode7Sprite2D::INTERPOLATION_PROJECTION && num_overrides >= 2) { + auto safe_transform = [&](int i) { Ref s = mode7_scanline_overrides[i]; return s.is_valid() ? s->get_transform() : Transform2D(); }; + auto safe_pivot = [&](int i) { Ref s = mode7_scanline_overrides[i]; return s.is_valid() ? s->get_pivot() : Vector2(0.5f, 0.5f); }; + auto safe_modulate = [&](int i) { Ref s = mode7_scanline_overrides[i]; return s.is_valid() ? s->get_modulate() : Color(1.0f, 1.0f, 1.0f, 1.0f); }; + + Transform2D transform_top = safe_transform(0); + Transform2D transform_bottom = safe_transform(num_overrides - 1); + Vector2 pivot_top = safe_pivot(0); + Vector2 pivot_bottom = safe_pivot(num_overrides - 1); + Color modulate_top = safe_modulate(0); + Color modulate_bottom = safe_modulate(num_overrides - 1); + + Vector2 s_top = transform_top.get_scale(); + Vector2 s_bot = transform_bottom.get_scale(); + real_t scale_top = 1.0f / MAX(s_top.x, 0.0001f); + real_t scale_bottom = 1.0f / MAX(s_bot.x, 0.0001f); + real_t rotation_top = transform_top.get_rotation(); + real_t rotation_bottom = transform_bottom.get_rotation(); + + real_t t = p_uv_y; + + real_t inv_s_top = 1.0f / MAX(scale_top, 0.0001f); + real_t inv_s_bot = 1.0f / MAX(scale_bottom, 0.0001f); + + real_t t_pa = (t * mode7_projection_pixel_aspect) / (t * mode7_projection_pixel_aspect + (1.0f - t)); + real_t t_g = Math::pow(t_pa, mode7_projection_gamma); + + real_t inv_s_cur = inv_s_top + (inv_s_bot - inv_s_top) * t_g; + real_t S = 1.0f / MAX(inv_s_cur, 0.0001f); + + real_t theta = rotation_top + (rotation_bottom - rotation_top) * t; + + if (mode7_projection_strength < 1.0f) { + real_t S_flat = (scale_top + scale_bottom) * 0.5f; + real_t theta_flat = (rotation_top + rotation_bottom) * 0.5f; + S = S_flat + (S - S_flat) * mode7_projection_strength; + theta = theta_flat + (theta - theta_flat) * mode7_projection_strength; + } + + real_t Sx = S * mode7_projection_aspect_ratio; + real_t Sy = S; + + real_t cos_t = Math::cos(theta); + real_t sin_t = Math::sin(theta); + + Vector2 col0(cos_t * Sx, -sin_t * Sy); + Vector2 col1(sin_t * Sx, cos_t * Sy); + + r_pivot = pivot_top.lerp(pivot_bottom, (real_t)t); + r_modulate = modulate_top.lerp(modulate_bottom, (real_t)t); + + Vector2 off_raw = transform_top.columns[2].lerp(transform_bottom.columns[2], (real_t)t); + + real_t s_linear = scale_top + (scale_bottom - scale_top) * t; + if (mode7_projection_strength < 1.0f) { + const real_t s_flat = (scale_top + scale_bottom) * 0.5f; + s_linear = s_flat + (s_linear - s_flat) * mode7_projection_strength; + } + real_t depth_factor = (s_linear > 0.001f) ? (S / s_linear - 1.0f) : 0.0f; + + off_raw += (transform_bottom.columns[2] - transform_top.columns[2]) * depth_factor; + + r_transform = Transform2D(col0, col1, off_raw); + } else { // Lerp or no interpolation + float idx_f = (num_overrides == 1) ? 0.0f : p_uv_y * (num_overrides - 1); + int idx_lo = CLAMP((int)idx_f, 0, num_overrides - 1); + int idx_hi = CLAMP(idx_lo + 1, 0, num_overrides - 1); + float frac = idx_f - (float)idx_lo; + + Ref entry_lo = mode7_scanline_overrides[idx_lo]; + Transform2D xf_lo = entry_lo.is_valid() ? entry_lo->get_transform() : Transform2D(); + Vector2 pivot_lo = entry_lo.is_valid() ? entry_lo->get_pivot() : Vector2(0.5f, 0.5f); + Color modulate_lo = entry_lo.is_valid() ? entry_lo->get_modulate() : Color(1.0f, 1.0f, 1.0f, 1.0f); + bool do_lerp = (interp_mode == Mode7Sprite2D::INTERPOLATION_LERP); + + if (do_lerp && idx_hi != idx_lo && frac > 0.0f) { + Ref entry_hi = mode7_scanline_overrides[idx_hi]; + Transform2D xf_hi = entry_hi.is_valid() ? entry_hi->get_transform() : Transform2D(); + Vector2 pivot_hi = entry_hi.is_valid() ? entry_hi->get_pivot() : Vector2(0.5f, 0.5f); + Color modulate_hi = entry_hi.is_valid() ? entry_hi->get_modulate() : Color(1.0f, 1.0f, 1.0f, 1.0f); + r_transform = xf_lo.interpolate_with(xf_hi, frac); + r_pivot = pivot_lo.lerp(pivot_hi, frac); + r_modulate = modulate_lo.lerp(modulate_hi, frac); + } else { + int idx_nearest = CLAMP((int)roundf(idx_f), 0, num_overrides - 1); + Ref entry_nearest = mode7_scanline_overrides[idx_nearest]; + r_transform = entry_nearest.is_valid() ? entry_nearest->get_transform() : Transform2D(); + r_pivot = entry_nearest.is_valid() ? entry_nearest->get_pivot() : Vector2(0.5f, 0.5f); + r_modulate = entry_nearest.is_valid() ? entry_nearest->get_modulate() : Color(1.0f, 1.0f, 1.0f, 1.0f); + } + } +} + + +Transform2D Mode7Sprite2D::_mode7_aspect_rotate(real_t p_angle, real_t p_aspect) { + real_t cr = Math::cos(p_angle); + real_t sr = Math::sin(p_angle); + // Mirrors the shader's mat2(vec2(cr, sr*aspect), vec2(-sr/aspect, cr)): + // col0 = (cr, sr*aspect), col1 = (-sr/aspect, cr). Origin unused by caller. + return Transform2D(Vector2(cr, sr * p_aspect), Vector2(-sr / p_aspect, cr), Vector2()); +} + +Vector2 Mode7Sprite2D::mode7_transform_point(const Vector2 &p_point) const { + Ref tex = get_texture(); + if (tex.is_null()) { + return p_point; + } + Vector2 tex_size = tex->get_size(); + if (tex_size.x <= 0.0f || tex_size.y <= 0.0f) { + return p_point; + } + + Rect2 src_rect, dst_rect; + bool unused_filter_clip = false; + _get_rects(src_rect, dst_rect, unused_filter_clip); + + // --- 1) p_point (parent-local, undistorted source point) -> normalized + // full-texture UV. This is unchanged from before and was validated + // correct against your test values. --- + Vector2 local_point = get_transform().affine_inverse().xform(p_point); + Vector2 tex_point = src_rect.position + (local_point - dst_rect.position) * (src_rect.size / dst_rect.size); + Vector2 source_full_uv = tex_point / tex_size; + + Rect2 region_px = is_region_enabled() ? get_region_rect() : Rect2(Vector2(), tex_size); + if (region_px.size.x == 0.0f || region_px.size.y == 0.0f) { + region_px.size = tex_size; + } + Rect2 region_rect_norm(region_px.position / tex_size, region_px.size / tex_size); + + // source_region_local is the known target -- this is what the shader + // calls "uv" right before the final `uv_full = uv * REGION_RECT.zw + REGION_RECT.xy` + // denormalization, i.e. AFTER per-scanline + global transform have been applied. + Vector2 source_region_local = (source_full_uv - region_rect_norm.position) / region_rect_norm.size; + + real_t region_aspect = 1.0f; + if (mode7_override_region_aspect && region_px.size.y != 0.0f) { + region_aspect = region_px.size.x / region_px.size.y; + } + + // --- 2) Invert the GLOBAL transform in closed form (doesn't depend on the + // unknown destination row, so no iteration needed here). --- + // Forward: source = matrix_global * (v - global_pivot + global_offset) + global_pivot + // where v is the value right after the per-scanline transform. + Transform2D matrix_global = _mode7_aspect_rotate(mode7_global_rotation, region_aspect); + Transform2D matrix_global_inv = matrix_global.affine_inverse(); + Vector2 v = mode7_global_pivot - mode7_global_offset + + matrix_global_inv.basis_xform(source_region_local - mode7_global_pivot); + + // --- 3) Invert the PER-SCANLINE transform via bisection on dest.y, since + // the matrix/pivot/offset for row `dy` depend on `dy` itself. + // + // Forward per-scanline: v = M(dy) * (dest - P(dy)) + P(dy) + O(dy) + // => dest = M(dy)^-1 * (v - P(dy) - O(dy)) + P(dy) + // + // We bisect on dy such that the candidate dest's own .y matches dy. + // This assumes the per-row transform varies monotonically with row + // (true for well-formed scanline overrides / projection interpolation; + // if overrides include extreme rotation/skew this may need more care). --- + auto solve_dest_for_v = [&](real_t p_dy, Vector2 &r_dest) { + Transform2D scan_transform; + Vector2 pivot; + Color unused_modulate; + _mode7_compute_scanline_data(p_dy, scan_transform, pivot, unused_modulate); + + Vector2 scan_offset = scan_transform.columns[2]; + Transform2D matrix_transformed(scan_transform.columns[0], scan_transform.columns[1], Vector2()); + Transform2D matrix_transformed_inv = matrix_transformed.affine_inverse(); + + r_dest = matrix_transformed_inv.basis_xform(v - pivot - scan_offset) + pivot; + }; + + real_t lo = 0.0f, hi = 1.0f; + Vector2 dest_lo, dest_hi; + solve_dest_for_v(lo, dest_lo); + solve_dest_for_v(hi, dest_hi); + real_t residual_lo = dest_lo.y - lo; + real_t residual_hi = dest_hi.y - hi; + + Vector2 dest = dest_lo; + if (SIGN(residual_lo) != SIGN(residual_hi) || residual_lo == 0.0f || residual_hi == 0.0f) { + // Standard bisection; ~40 iterations is comfortably more than enough + // for float/real_t precision on a [0,1] interval. + for (int i = 0; i < 40; i++) { + real_t mid = (lo + hi) * 0.5f; + Vector2 dest_mid; + solve_dest_for_v(mid, dest_mid); + real_t residual_mid = dest_mid.y - mid; + + if (SIGN(residual_mid) == SIGN(residual_lo)) { + lo = mid; + residual_lo = residual_mid; + } else { + hi = mid; + residual_hi = residual_mid; + } + dest = dest_mid; + } + } else { + // No sign change across [0,1]: the target row is outside the + // representable range (point maps off the top/bottom of the + // transformed image). Clamp to whichever endpoint is closer. + dest = (Math::abs(residual_lo) < Math::abs(residual_hi)) ? dest_lo : dest_hi; + } + + // --- 4) dest (region-local UV) -> full-texture UV -> texture pixels -> + // dst_rect (forward direction) -> back to caller's space. --- + Vector2 dest_full_uv = dest * region_rect_norm.size + region_rect_norm.position; + Vector2 tex_point_out = dest_full_uv * tex_size; + Vector2 local_point_out = dst_rect.position + (tex_point_out - src_rect.position) * (dst_rect.size / src_rect.size); + + return get_transform().xform(local_point_out); +} + + void Mode7Sprite2D::set_mode7_tiling(bool p_tiling) { if (mode7_tiling == p_tiling) { return; @@ -998,6 +1053,7 @@ void Mode7Sprite2D::_bind_methods() { ClassDB::bind_method(D_METHOD("set_mode7_saved_material", "material"), &Mode7Sprite2D::set_mode7_saved_material); ClassDB::bind_method(D_METHOD("get_mode7_saved_material"), &Mode7Sprite2D::get_mode7_saved_material); + ClassDB::bind_method(D_METHOD("mode7_transform_point", "uv"), &Mode7Sprite2D::mode7_transform_point); // Properties (exposed in the Inspector) ----------------------------------- diff --git a/scene/2d/mode7_sprite_2d.h b/scene/2d/mode7_sprite_2d.h index 373c0f580c2..41df443359e 100644 --- a/scene/2d/mode7_sprite_2d.h +++ b/scene/2d/mode7_sprite_2d.h @@ -125,6 +125,12 @@ class Mode7Sprite2D : public Sprite2D { /// Exposed for manual refresh (e.g., after a scene reload) without waiting for ENTER_TREE or setter calls. void force_update_follow_cache(); + /// Computes the resulting point, in normalized region-local UV space [0,1]x[0,1], + /// after applying the same per-scanline + global Mode 7 transformation that the + /// shader's fragment() function applies to UV. This is the CPU-side equivalent of + /// "where does this point move to" for a single x/y coordinate. + Vector2 mode7_transform_point(const Vector2 &p_uv) const; + Mode7Sprite2D(); private: @@ -144,6 +150,23 @@ class Mode7Sprite2D : public Sprite2D { /// Builds the transform, pivot/offset and color/modulate as 3 "Color" values per row /// We're only after an actual color for the modulate value, though. The rest, we're using the vec4 for data. void _mode7_rebuild_scanline_texture(); + + /// Computes the interpolated per-scanline Transform2D and pivot for a given + /// normalized row coordinate (region-local uv.y, 0..1), using whichever + /// mode7_interpolation mode is active (NONE/LERP/PROJECTION). This is the + /// single shared implementation of the "row data" math used both by + /// _mode7_rebuild_scanline_texture() (baking the scanline table) and by + /// mode7_transform_point() (exact per-point evaluation, not limited to the + /// scanline table's 1024-row resolution/precision). + void _mode7_compute_scanline_data(real_t p_uv_y, Transform2D &r_transform, Vector2 &r_pivot, Color &r_modulate) const; + + /// C++ equivalent of the shader's aspect_rotate(angle, aspect) helper: + /// builds a rotation basis pre/post scaled by aspect so a non-square + /// region doesn't shear the rotation. Returned as a Transform2D with a + /// zero origin (only the basis columns matter); origin/pivot handling is + /// left to the caller. + static Transform2D _mode7_aspect_rotate(real_t p_angle, real_t p_aspect); + /// Shared tail for the projection tuning setters: these four parameters only feed the /// scanline table (not the shader uniforms), so when the material already exists we /// rebuild just the table (rebinding it to the material) and request a redraw — diff --git a/scene/2d/sprite_2d.h b/scene/2d/sprite_2d.h index 988dc4562fc..d52acb4748c 100644 --- a/scene/2d/sprite_2d.h +++ b/scene/2d/sprite_2d.h @@ -66,7 +66,6 @@ class Sprite2D : public Node2D { int vframes = 1; int hframes = 1; - void _get_rects(Rect2 &r_src_rect, Rect2 &r_dst_rect, bool &r_filter_clip_enabled) const; Point2 _get_rect_offset(const Size2i &p_size) const; /// Changes to the texture need to trigger an update to make @@ -81,6 +80,8 @@ class Sprite2D : public Node2D { void _validate_property(PropertyInfo &p_property) const; + void _get_rects(Rect2 &r_src_rect, Rect2 &r_dst_rect, bool &r_filter_clip_enabled) const; + public: #ifdef TOOLS_ENABLED virtual Dictionary _edit_get_state() const override; From b3a4fc3e9aa221fd8b07c2e8dfad327045aec7f5 Mon Sep 17 00:00:00 2001 From: GeneralProtectionFault Date: Thu, 10 Sep 2026 19:55:50 -0400 Subject: [PATCH 2/3] Transform point works with region! --- doc/classes/Mode7Sprite2D.xml | 10 ++ scene/2d/mode7_sprite_2d.cpp | 324 +++++++++++++++++++++++++++------- scene/2d/mode7_sprite_2d.h | 19 +- 3 files changed, 282 insertions(+), 71 deletions(-) diff --git a/doc/classes/Mode7Sprite2D.xml b/doc/classes/Mode7Sprite2D.xml index 97a1673f8cb..330032f03fb 100644 --- a/doc/classes/Mode7Sprite2D.xml +++ b/doc/classes/Mode7Sprite2D.xml @@ -32,6 +32,16 @@ Re-resolve the follow target from [member mode7_region_follow_target] and store it in the internal follow cache. Useful after a scene reload when you need to refresh the tracked target without waiting for [constant Node.NOTIFICATION_ENTER_TREE]. + + + + + Provide a (Vector2) position, which should be the "real" position on the un-transformed sprite (Mode 7 disabled). + The function will return a position, which will be that same place on the sprite after the Mode 7 transformations are applied. + This can be used to place/move an object on the sprite consistent with the Mode 7 transformation. + WARNING: There are edge cases where this will not work, such as the full sprite (not using region) with tiling, essentially if you're looking for a point that is outside the entire sprite's UV space (essentially, on one of the "tiles"). + + diff --git a/scene/2d/mode7_sprite_2d.cpp b/scene/2d/mode7_sprite_2d.cpp index cadc0686386..903b957026c 100644 --- a/scene/2d/mode7_sprite_2d.cpp +++ b/scene/2d/mode7_sprite_2d.cpp @@ -42,6 +42,7 @@ #include "scene/2d/mode7_scanline_override.h" #include "scene/2d/mode7_sprite_2d.h" #include "scene/main/node.h" +#include "scene/main/viewport.h" #include "scene/resources/image_texture.h" #include "scene/resources/material.h" #include "scene/resources/shader.h" @@ -277,7 +278,6 @@ void Mode7Sprite2D::_mode7_rebuild_scanline_texture() { } } - void Mode7Sprite2D::_mode7_compute_scanline_data(real_t p_uv_y, Transform2D &r_transform, Vector2 &r_pivot, Color &r_modulate) const { Mode7Sprite2D::Mode7InterpolationMode interp_mode = mode7_interpolation; int num_overrides = mode7_scanline_overrides.size(); @@ -382,7 +382,6 @@ void Mode7Sprite2D::_mode7_compute_scanline_data(real_t p_uv_y, Transform2D &r_t } } - Transform2D Mode7Sprite2D::_mode7_aspect_rotate(real_t p_angle, real_t p_aspect) { real_t cr = Math::cos(p_angle); real_t sr = Math::sin(p_angle); @@ -391,7 +390,56 @@ Transform2D Mode7Sprite2D::_mode7_aspect_rotate(real_t p_angle, real_t p_aspect) return Transform2D(Vector2(cr, sr * p_aspect), Vector2(-sr / p_aspect, cr), Vector2()); } +void Mode7Sprite2D::_mode7_get_full_rects(Rect2 &r_src_rect, Rect2 &r_dst_rect) const { + // Mirrors Sprite2D::_get_rects() exactly, except base_rect is always the + // full texture, never the cropped region_rect -- this is the "virtual" + // full-image placement that point-space conversion needs, so that a + // point far outside the currently-visible region crop still maps + // correctly instead of being linearly extrapolated from a tiny quad. + Ref tex = get_texture(); + Rect2 base_rect = Rect2(0, 0, tex->get_width(), tex->get_height()); + + Size2 frame_size = base_rect.size / Size2(get_hframes(), get_vframes()); + Point2 frame_offset = Point2(get_frame() % get_hframes(), get_frame() / get_hframes()); + frame_offset *= frame_size; + + r_src_rect.size = frame_size; + r_src_rect.position = base_rect.position + frame_offset; + + Point2 dest_offset = get_offset(); + if (is_centered()) { + dest_offset -= frame_size / 2; + } + + if (get_viewport() && get_viewport()->is_snap_2d_transforms_to_pixel_enabled()) { + dest_offset = (dest_offset + Point2(0.5, 0.5)).floor(); + } + + r_dst_rect = Rect2(dest_offset, frame_size); + + if (is_flipped_h()) { + r_dst_rect.size.x = -r_dst_rect.size.x; + } + if (is_flipped_v()) { + r_dst_rect.size.y = -r_dst_rect.size.y; + } +} + Vector2 Mode7Sprite2D::mode7_transform_point(const Vector2 &p_point) const { + // CPU-side inverse of the Mode7 fragment shader's per-pixel sampling math. + // The shader computes, per screen pixel ("dest"), which texture coordinate + // to sample ("source"): source = GlobalTransform(PerScanlineTransform(dest)). + // Callers want the opposite: given a point on the UNDISTORTED source artwork, + // find where it visually ends up once Mode7's warping is applied -- i.e. + // solve for "dest" given "source". Because the per-scanline matrix is itself + // selected using dest.y (the very thing being solved for), this requires a + // numeric solve (bisection), not a closed-form formula. + + // Mode 7 is off, so the sprite draws unwarped and the point does not move. + if (!mode7_enabled) { + return p_point; + } + Ref tex = get_texture(); if (tex.is_null()) { return p_point; @@ -401,108 +449,252 @@ Vector2 Mode7Sprite2D::mode7_transform_point(const Vector2 &p_point) const { return p_point; } - Rect2 src_rect, dst_rect; - bool unused_filter_clip = false; - _get_rects(src_rect, dst_rect, unused_filter_clip); - - // --- 1) p_point (parent-local, undistorted source point) -> normalized - // full-texture UV. This is unchanged from before and was validated - // correct against your test values. --- + // --- 1) p_point (parent-local space, a point on the undistorted source + // artwork) -> texture-pixel space -> normalized full-texture UV. + // This MUST use the full, uncropped virtual rects, not + // Sprite2D::_get_rects() -- region_rect only says which slice is + // currently visible, it doesn't move where the full artwork sits. + // Using the cropped rects here would extrapolate from the tiny + // visible quad and produce garbage for points outside the region. --- + Rect2 full_src_rect, full_dst_rect; + _mode7_get_full_rects(full_src_rect, full_dst_rect); + + // Undo this node's own Transform2D so we're working in the sprite's own + // local drawing space, the same space full_dst_rect is defined in. Vector2 local_point = get_transform().affine_inverse().xform(p_point); - Vector2 tex_point = src_rect.position + (local_point - dst_rect.position) * (src_rect.size / dst_rect.size); + + // Map local_point from full_dst_rect (local space) into full_src_rect + // (texture-pixel space) by ratio, then normalize by the full texture size. + // This is "source_full_uv" -- the same UV space REGION_RECT is defined in. + Vector2 tex_point = full_src_rect.position + (local_point - full_dst_rect.position) * (full_src_rect.size / full_dst_rect.size); Vector2 source_full_uv = tex_point / tex_size; + // DEBUG: print_line(vformat("source_full_uv=%s", source_full_uv)); + + // --- 2) Resolve the active region, in pixel space and normalized + // full-texture UV. No region enabled == whole texture, matching the + // shader's REGION_RECT = (0,0,1,1) no-op case. --- Rect2 region_px = is_region_enabled() ? get_region_rect() : Rect2(Vector2(), tex_size); if (region_px.size.x == 0.0f || region_px.size.y == 0.0f) { + // Defensive: a degenerate region would otherwise divide by zero below. region_px.size = tex_size; } Rect2 region_rect_norm(region_px.position / tex_size, region_px.size / tex_size); - // source_region_local is the known target -- this is what the shader - // calls "uv" right before the final `uv_full = uv * REGION_RECT.zw + REGION_RECT.xy` - // denormalization, i.e. AFTER per-scanline + global transform have been applied. - Vector2 source_region_local = (source_full_uv - region_rect_norm.position) / region_rect_norm.size; - + // Region aspect ratio in real texture pixels -- only used to correct the + // GLOBAL rotation step below when the region isn't square (mirrors the + // shader's region_aspect, which only ever feeds aspect_rotate() for + // mode7_global_rotation, never the per-scanline scale itself). real_t region_aspect = 1.0f; if (mode7_override_region_aspect && region_px.size.y != 0.0f) { region_aspect = region_px.size.x / region_px.size.y; } - // --- 2) Invert the GLOBAL transform in closed form (doesn't depend on the - // unknown destination row, so no iteration needed here). --- - // Forward: source = matrix_global * (v - global_pivot + global_offset) + global_pivot - // where v is the value right after the per-scanline transform. + // DEBUG: print_line(vformat("region_px=%s region_rect_norm.pos=%s region_rect_norm.size=%s source_full_uv=%s", + // region_px, region_rect_norm.position, region_rect_norm.size, source_full_uv)); + + // --- 3) Invert the GLOBAL transform step (rotation about + // mode7_global_pivot, then offset). This part IS closed-form, since + // global rotation doesn't depend on the unknown per-scanline row. + // Exact algebraic inverse of the shader's: + // uv = matrix_global * (uv - mode7_global_pivot) + mode7_global_pivot; + // uv += matrix_global * mode7_global_offset; + // -> inter = matrix_global^-1 * (target - pivot - matrix_global*offset) + pivot --- Transform2D matrix_global = _mode7_aspect_rotate(mode7_global_rotation, region_aspect); Transform2D matrix_global_inv = matrix_global.affine_inverse(); - Vector2 v = mode7_global_pivot - mode7_global_offset + - matrix_global_inv.basis_xform(source_region_local - mode7_global_pivot); - // --- 3) Invert the PER-SCANLINE transform via bisection on dest.y, since - // the matrix/pivot/offset for row `dy` depend on `dy` itself. - // - // Forward per-scanline: v = M(dy) * (dest - P(dy)) + P(dy) + O(dy) - // => dest = M(dy)^-1 * (v - P(dy) - O(dy)) + P(dy) - // - // We bisect on dy such that the candidate dest's own .y matches dy. - // This assumes the per-row transform varies monotonically with row - // (true for well-formed scanline overrides / projection interpolation; - // if overrides include extreme rotation/skew this may need more care). --- - auto solve_dest_for_v = [&](real_t p_dy, Vector2 &r_dest) { + // Per-scanline inverse solve for a single candidate row "p_dy": given a + // target point in region-local UV space, find "dest" such that the + // per-scanline transform (looked up at p_dy) maps dest -> the target. + // Same lookup _mode7_rebuild_scanline_texture() uses to bake the scanline + // table, evaluated here at full precision instead of the table's + // 1024-row resolution. + auto solve_dest_for_v = [&](const Vector2 &p_v, real_t p_dy, Vector2 &r_dest) -> bool { Transform2D scan_transform; Vector2 pivot; Color unused_modulate; _mode7_compute_scanline_data(p_dy, scan_transform, pivot, unused_modulate); Vector2 scan_offset = scan_transform.columns[2]; + // Strip translation to invert the 2x2 basis alone; affine_inverse() + // is a true general inverse (not assuming orthonormality), which + // matters once rotation/skew are non-zero. Transform2D matrix_transformed(scan_transform.columns[0], scan_transform.columns[1], Vector2()); + if (Math::is_zero_approx(matrix_transformed.determinant())) { + // A degenerate override basis (zero scale or collinear columns) has no + // inverse; the row collapses and no source point maps through it. + r_dest = Vector2(p_v.x, p_dy); + return false; + } Transform2D matrix_transformed_inv = matrix_transformed.affine_inverse(); - r_dest = matrix_transformed_inv.basis_xform(v - pivot - scan_offset) + pivot; + // Inverse of: target = matrix_transformed * (dest - pivot) + pivot + scan_offset + r_dest = matrix_transformed_inv.basis_xform(p_v - pivot - scan_offset) + pivot; + return true; }; - real_t lo = 0.0f, hi = 1.0f; - Vector2 dest_lo, dest_hi; - solve_dest_for_v(lo, dest_lo); - solve_dest_for_v(hi, dest_hi); - real_t residual_lo = dest_lo.y - lo; - real_t residual_hi = dest_hi.y - hi; - - Vector2 dest = dest_lo; - if (SIGN(residual_lo) != SIGN(residual_hi) || residual_lo == 0.0f || residual_hi == 0.0f) { - // Standard bisection; ~40 iterations is comfortably more than enough - // for float/real_t precision on a [0,1] interval. - for (int i = 0; i < 40; i++) { - real_t mid = (lo + hi) * 0.5f; - Vector2 dest_mid; - solve_dest_for_v(mid, dest_mid); - real_t residual_mid = dest_mid.y - mid; - - if (SIGN(residual_mid) == SIGN(residual_lo)) { - lo = mid; - residual_lo = residual_mid; - } else { - hi = mid; - residual_hi = residual_mid; + // Solves for "dest" given a full candidate target "p_v" (already in + // region-local UV space). Bisects on dy in [0,1] to find the row whose + // per-scanline transform is self-consistent (dest.y == dy). Reports + // whether a genuine root exists (sign change across [0,1]) via + // r_is_valid_root -- a "dest" that merely lands inside [0,1] after the + // no-sign-change clamp fallback is NOT a valid mapping and must not be + // mistaken for one by the wrap-candidate search in step 4. + auto solve_for_candidate = [&](const Vector2 &p_v, Vector2 &r_dest, bool &r_is_valid_root) { + Vector2 inter = matrix_global_inv.basis_xform(p_v - mode7_global_pivot - matrix_global.basis_xform(mode7_global_offset)) + mode7_global_pivot; + + real_t lo = 0.0f, hi = 1.0f; + Vector2 dest_lo, dest_hi; + const bool ok_lo = solve_dest_for_v(inter, lo, dest_lo); + const bool ok_hi = solve_dest_for_v(inter, hi, dest_hi); + if (!ok_lo || !ok_hi) { + r_dest = ok_lo ? dest_lo : dest_hi; + r_is_valid_root = false; + return; + } + + real_t residual_lo = dest_lo.y - lo; + real_t residual_hi = dest_hi.y - hi; + + Vector2 dest = dest_lo; + if (SIGN(residual_lo) != SIGN(residual_hi) || residual_lo == 0.0f || residual_hi == 0.0f) { + // Sign change: a genuine root exists in this interval. Standard + // bisection; ~40 iterations is comfortably enough for + // float/real_t precision on a unit interval. + for (int i = 0; i < 40; i++) { + real_t mid = (lo + hi) * 0.5f; + Vector2 dest_mid; + if (!solve_dest_for_v(inter, mid, dest_mid)) { + break; + } + real_t residual_mid = dest_mid.y - mid; + + if (SIGN(residual_mid) == SIGN(residual_lo)) { + lo = mid; + residual_lo = residual_mid; + } else { + hi = mid; + residual_hi = residual_mid; + } + dest = dest_mid; + } + r_is_valid_root = true; + } else { + // No sign change: no row satisfies the self-consistency equation + // for this candidate -- it doesn't map anywhere onto the visible + // transformed image. The clamp-to-nearest-endpoint value is only + // useful as a last-resort fallback, never a real answer. + dest = (Math::abs(residual_lo) < Math::abs(residual_hi)) ? dest_lo : dest_hi; + r_is_valid_root = false; + } + r_dest = dest; + }; + + // --- 4) Resolve which tiled copy of source_full_uv is the correct one to + // feed the solver. Only matters when mode7_tiling is enabled: the + // region can drift arbitrarily far outside [0, tex_size] (that's the + // point of mode7_region_follow_target + tiling -- a seamlessly + // scrolling region), and the shader only wraps at the very end + // (fract(uv_full)), after all per-scanline/global math. Going + // backwards, one source point can correspond to several + // integer-shifted candidates in region-local space, but only some of + // those are real (self-consistent) roots at all. + // + // A single "nearest wrap to region center" guess isn't reliable once + // the region is large/off-center relative to the texture (a source + // point near one edge of a big region can need a different wrap than + // the region's own center) -- so instead this searches a small grid + // of wraps around that center guess, keeps only candidates that are + // BOTH a genuine root AND land inside [0,1]x[0,1], and among those + // picks the one whose unwrapped position is closest to the region + // center. If none qualify, falls back to the original center-nearest + // guess so there's always some answer. --- + Vector2 dest; + if (mode7_tiling) { + Vector2 region_center_full_uv = region_rect_norm.position + region_rect_norm.size * 0.5f; + Vector2 n_center = (region_center_full_uv - source_full_uv).round(); + + bool found_valid = false; + real_t best_dist = 0.0f; + Vector2 best_dest; + Vector2 fallback_dest; + bool have_fallback = false; + + const int SEARCH_RADIUS = 2; + for (int kx = -SEARCH_RADIUS; kx <= SEARCH_RADIUS; kx++) { + for (int ky = -SEARCH_RADIUS; ky <= SEARCH_RADIUS; ky++) { + Vector2 n = n_center + Vector2((real_t)kx, (real_t)ky); + Vector2 candidate_full_uv = source_full_uv + n; + Vector2 candidate_region_local = (candidate_full_uv - region_rect_norm.position) / region_rect_norm.size; + + Vector2 candidate_dest; + bool is_valid_root = false; + solve_for_candidate(candidate_region_local, candidate_dest, is_valid_root); + + // Keep the center-nearest candidate as a fallback regardless + // of validity, in case nothing in the grid qualifies. + if (n.is_equal_approx(n_center)) { + fallback_dest = candidate_dest; + have_fallback = true; + } + + bool in_range = candidate_dest.x >= 0.0f && candidate_dest.x <= 1.0f && + candidate_dest.y >= 0.0f && candidate_dest.y <= 1.0f; + if (!is_valid_root || !in_range) { + continue; + } + + real_t dist = n.distance_squared_to(n_center); + if (!found_valid || dist < best_dist) { + found_valid = true; + best_dist = dist; + best_dest = candidate_dest; + } } - dest = dest_mid; } + + dest = found_valid ? best_dest : (have_fallback ? fallback_dest : Vector2()); + + // DEBUG: print_line(vformat("n_center=%s found_valid=%s dest=%s", n_center, found_valid ? "true" : "false", dest)); } else { - // No sign change across [0,1]: the target row is outside the - // representable range (point maps off the top/bottom of the - // transformed image). Clamp to whichever endpoint is closer. - dest = (Math::abs(residual_lo) < Math::abs(residual_hi)) ? dest_lo : dest_hi; + // No tiling: source_full_uv is unambiguous, so region-local mapping + // is a single direct divide -- no wraparound ambiguity to resolve. + Vector2 source_region_local = (source_full_uv - region_rect_norm.position) / region_rect_norm.size; + bool unused_is_valid_root = false; + solve_for_candidate(source_region_local, dest, unused_is_valid_root); + } + + // --- 5) dest (region-local UV, the resolved screen-space coordinate) -> + // full-texture UV -> texture-pixel space -> back through the actual, + // region-cropped dst_rect/src_rect (forward direction this time) -> + // back through this node's own Transform2D, landing in the same + // space p_point was given in. mode7_tiling wrap is intentionally NOT + // re-applied here: dest is already the resolved, unambiguous + // screen-space answer -- wrapping only applies to the shader's own + // forward sampling step. This step deliberately uses the REAL, + // region-cropped rects (unlike step 1), because that's what the + // renderer actually draws: the visible sprite is only as big as the + // cropped region's quad, not the full texture. --- + Rect2 cropped_src_rect, cropped_dst_rect; + bool unused_filter_clip = false; + _get_rects(cropped_src_rect, cropped_dst_rect, unused_filter_clip); + + if (cropped_src_rect.size.x == 0.0f || cropped_src_rect.size.y == 0.0f) { + // Same degeneracy the region_px guard above handles: a zero-size region + // leaves no quad to map back into, so there is no meaningful answer. + return p_point; } - // --- 4) dest (region-local UV) -> full-texture UV -> texture pixels -> - // dst_rect (forward direction) -> back to caller's space. --- Vector2 dest_full_uv = dest * region_rect_norm.size + region_rect_norm.position; Vector2 tex_point_out = dest_full_uv * tex_size; - Vector2 local_point_out = dst_rect.position + (tex_point_out - src_rect.position) * (dst_rect.size / src_rect.size); + Vector2 local_point_out = cropped_dst_rect.position + (tex_point_out - cropped_src_rect.position) * (cropped_dst_rect.size / cropped_src_rect.size); + + // DEBUG: print_line(vformat("dest_full_uv=%s tex_point_out=%s cropped_src_rect=%s cropped_dst_rect=%s", + // dest_full_uv, tex_point_out, cropped_src_rect, cropped_dst_rect)); return get_transform().xform(local_point_out); } - void Mode7Sprite2D::set_mode7_tiling(bool p_tiling) { if (mode7_tiling == p_tiling) { return; @@ -1053,7 +1245,7 @@ void Mode7Sprite2D::_bind_methods() { ClassDB::bind_method(D_METHOD("set_mode7_saved_material", "material"), &Mode7Sprite2D::set_mode7_saved_material); ClassDB::bind_method(D_METHOD("get_mode7_saved_material"), &Mode7Sprite2D::get_mode7_saved_material); - ClassDB::bind_method(D_METHOD("mode7_transform_point", "uv"), &Mode7Sprite2D::mode7_transform_point); + ClassDB::bind_method(D_METHOD("mode7_transform_point", "point"), &Mode7Sprite2D::mode7_transform_point); // Properties (exposed in the Inspector) ----------------------------------- diff --git a/scene/2d/mode7_sprite_2d.h b/scene/2d/mode7_sprite_2d.h index 41df443359e..496153084d1 100644 --- a/scene/2d/mode7_sprite_2d.h +++ b/scene/2d/mode7_sprite_2d.h @@ -125,11 +125,10 @@ class Mode7Sprite2D : public Sprite2D { /// Exposed for manual refresh (e.g., after a scene reload) without waiting for ENTER_TREE or setter calls. void force_update_follow_cache(); - /// Computes the resulting point, in normalized region-local UV space [0,1]x[0,1], - /// after applying the same per-scanline + global Mode 7 transformation that the - /// shader's fragment() function applies to UV. This is the CPU-side equivalent of - /// "where does this point move to" for a single x/y coordinate. - Vector2 mode7_transform_point(const Vector2 &p_uv) const; + /// Takes a point in this node's parent-local space, on the undistorted source artwork, + /// @return The point in the same space after the per-scanline global Mode 7 transformation that the shader's fragment() function applies. + /// This is the CPU-side equivalent of "where does this point move to?" + Vector2 mode7_transform_point(const Vector2 &p_point) const; Mode7Sprite2D(); @@ -167,6 +166,16 @@ class Mode7Sprite2D : public Sprite2D { /// left to the caller. static Transform2D _mode7_aspect_rotate(real_t p_angle, real_t p_aspect); + /// Computes the same src_rect/dst_rect pairing Sprite2D::_get_rects() would + /// produce, but always against the FULL, uncropped texture (ignoring + /// region_enabled/region_rect). This represents "where would this sprite's + /// full artwork be positioned in local space," independent of which slice + /// is currently visible through the region crop. Used by + /// mode7_transform_point() so that point-space conversion stays anchored + /// to the whole image, while the region only ever affects the region-local + /// normalization step in between. + void _mode7_get_full_rects(Rect2 &r_src_rect, Rect2 &r_dst_rect) const; + /// Shared tail for the projection tuning setters: these four parameters only feed the /// scanline table (not the shader uniforms), so when the material already exists we /// rebuild just the table (rebinding it to the material) and request a redraw — From dd8b730335176f8cfabfc2811421c49613eef2a0 Mon Sep 17 00:00:00 2001 From: GeneralProtectionFault Date: Sat, 19 Sep 2026 13:16:21 -0400 Subject: [PATCH 3/3] Improve transform point logic --- doc/classes/Mode7Sprite2D.xml | 12 +- scene/2d/mode7_sprite_2d.cpp | 388 +++++++++++++++++----------------- scene/2d/mode7_sprite_2d.h | 8 +- 3 files changed, 205 insertions(+), 203 deletions(-) diff --git a/doc/classes/Mode7Sprite2D.xml b/doc/classes/Mode7Sprite2D.xml index 330032f03fb..396b781436f 100644 --- a/doc/classes/Mode7Sprite2D.xml +++ b/doc/classes/Mode7Sprite2D.xml @@ -33,13 +33,21 @@ - + + Provide a (Vector2) position, which should be the "real" position on the un-transformed sprite (Mode 7 disabled). The function will return a position, which will be that same place on the sprite after the Mode 7 transformations are applied. This can be used to place/move an object on the sprite consistent with the Mode 7 transformation. - WARNING: There are edge cases where this will not work, such as the full sprite (not using region) with tiling, essentially if you're looking for a point that is outside the entire sprite's UV space (essentially, on one of the "tiles"). + + This is the CPU-side inverse of the shader's per-pixel sampling: it finds where on the (drawn) sprite the given texel ends up, exactly matching the on-screen result. + It works whether or not a region is enabled, and handles None/Lerp (any number of scanline overrides) and Projection interpolation. + + With [member mode7_tiling] enabled, a point that lies outside the sprite's own UV space (on a repeated tile) is resolved to the correct position on that tile, taking global rotation into account. + + [b]visible_area_only:[/b] When [code]true[/code], the method returns [code]null[/code] if the point has no drawn destination (for example, it falls in the area the Mode 7 warp does not cover, such as the "blank" band a projection leaves when tiling is off). + When [code]false[/code] (default), it always returns the correct transformed location even if that area is not currently drawn, so you can still position objects there. diff --git a/scene/2d/mode7_sprite_2d.cpp b/scene/2d/mode7_sprite_2d.cpp index 903b957026c..493e1ab3676 100644 --- a/scene/2d/mode7_sprite_2d.cpp +++ b/scene/2d/mode7_sprite_2d.cpp @@ -425,15 +425,35 @@ void Mode7Sprite2D::_mode7_get_full_rects(Rect2 &r_src_rect, Rect2 &r_dst_rect) } } -Vector2 Mode7Sprite2D::mode7_transform_point(const Vector2 &p_point) const { +Variant Mode7Sprite2D::mode7_transform_point(const Vector2 &p_point, bool p_visible_area_only) const { // CPU-side inverse of the Mode7 fragment shader's per-pixel sampling math. - // The shader computes, per screen pixel ("dest"), which texture coordinate - // to sample ("source"): source = GlobalTransform(PerScanlineTransform(dest)). - // Callers want the opposite: given a point on the UNDISTORTED source artwork, - // find where it visually ends up once Mode7's warping is applied -- i.e. - // solve for "dest" given "source". Because the per-scanline matrix is itself - // selected using dest.y (the very thing being solved for), this requires a - // numeric solve (bisection), not a closed-form formula. + // + // FORWARD (the shader), per dest pixel v in the drawn region quad (region-local [0,1]^2): + // uv = M(r)*(v - p(r)) + p(r) + o(r) // per-row affine, r = v.y + // uv = G*(uv - gp) + gp + G*go // global rotation about gp, then offset + // uv_full = uv*R.zw + R.xy // denormalize to full-texture UV + // sampled = tiling ? fract(uv_full) : uv_full // R = REGION_RECT (normalized) + // (discarded if !tiling && uv outside [0,1]^2; horizon-masked alpha per v) + // + // INVERSE (this function): given a target texel T (full-texture UV of the input point), + // find the dest pixel v where the sprite displays T. Everything is exact algebra except the + // row r = v.y, which appears both as the self-consistency condition and inside M(r), p(r), + // o(r). That leaves a single 1-D equation H(r) = v(r).y - r = 0 (r in [0,1]), solved by a + // deterministic grid scan + local refinement. This is robust across NONE / LERP (any number + // of overrides) / PROJECTION and needs no global-monotonicity assumption (the old single + // bisection assumed one sign change and fell back to a wrong endpoint, causing the + // "spiral"/"jump" with LERP/NONE and region+projection). + // + // With tiling, fract(T(v)) = fract(P) has candidates T = P + n (n integer). Each candidate has + // at most one valid dest (a self-consistent root that is NOT a degenerate 180-degree fold + // and that lies inside the quad). The correct one is chosen by the candidate n whose forward + // map actually equals P + n (a validity gate that eliminates spurious roots), preferring the n + // nearest the region -- i.e. the tile the input point belongs to. This is a deterministic + // selection based on where the input point is, not a guess, and does not use visibility. + // + // Returns the dest position in the same (parent-local) space p_point was given in, or + // null when p_visible_area_only is true and the point has no visible (drawn, unmasked) + // destination. // Mode 7 is off, so the sprite draws unwarped and the point does not move. if (!mode7_enabled) { @@ -449,250 +469,220 @@ Vector2 Mode7Sprite2D::mode7_transform_point(const Vector2 &p_point) const { return p_point; } - // --- 1) p_point (parent-local space, a point on the undistorted source - // artwork) -> texture-pixel space -> normalized full-texture UV. - // This MUST use the full, uncropped virtual rects, not - // Sprite2D::_get_rects() -- region_rect only says which slice is - // currently visible, it doesn't move where the full artwork sits. - // Using the cropped rects here would extrapolate from the tiny - // visible quad and produce garbage for points outside the region. --- + // --- 1) p_point (parent-local, a point on the undistorted source artwork) + // -> local drawing space -> full-texture UV. Uses the full, uncropped + // virtual rects so points outside the visible region crop still map to + // the correct texel. (Same as before.) --- Rect2 full_src_rect, full_dst_rect; _mode7_get_full_rects(full_src_rect, full_dst_rect); - // Undo this node's own Transform2D so we're working in the sprite's own - // local drawing space, the same space full_dst_rect is defined in. Vector2 local_point = get_transform().affine_inverse().xform(p_point); - - // Map local_point from full_dst_rect (local space) into full_src_rect - // (texture-pixel space) by ratio, then normalize by the full texture size. - // This is "source_full_uv" -- the same UV space REGION_RECT is defined in. Vector2 tex_point = full_src_rect.position + (local_point - full_dst_rect.position) * (full_src_rect.size / full_dst_rect.size); Vector2 source_full_uv = tex_point / tex_size; - // DEBUG: print_line(vformat("source_full_uv=%s", source_full_uv)); - - // --- 2) Resolve the active region, in pixel space and normalized - // full-texture UV. No region enabled == whole texture, matching the - // shader's REGION_RECT = (0,0,1,1) no-op case. --- + // --- 2) Active region in normalized full-texture UV (no region == whole texture). Rect2 region_px = is_region_enabled() ? get_region_rect() : Rect2(Vector2(), tex_size); if (region_px.size.x == 0.0f || region_px.size.y == 0.0f) { - // Defensive: a degenerate region would otherwise divide by zero below. region_px.size = tex_size; } - Rect2 region_rect_norm(region_px.position / tex_size, region_px.size / tex_size); + Rect2 R(region_px.position / tex_size, region_px.size / tex_size); // REGION_RECT - // Region aspect ratio in real texture pixels -- only used to correct the - // GLOBAL rotation step below when the region isn't square (mirrors the - // shader's region_aspect, which only ever feeds aspect_rotate() for - // mode7_global_rotation, never the per-scanline scale itself). + // Region aspect for the aspect-corrected global rotation (mirrors the shader). real_t region_aspect = 1.0f; if (mode7_override_region_aspect && region_px.size.y != 0.0f) { region_aspect = region_px.size.x / region_px.size.y; } - // DEBUG: print_line(vformat("region_px=%s region_rect_norm.pos=%s region_rect_norm.size=%s source_full_uv=%s", - // region_px, region_rect_norm.position, region_rect_norm.size, source_full_uv)); - - // --- 3) Invert the GLOBAL transform step (rotation about - // mode7_global_pivot, then offset). This part IS closed-form, since - // global rotation doesn't depend on the unknown per-scanline row. - // Exact algebraic inverse of the shader's: - // uv = matrix_global * (uv - mode7_global_pivot) + mode7_global_pivot; - // uv += matrix_global * mode7_global_offset; - // -> inter = matrix_global^-1 * (target - pivot - matrix_global*offset) + pivot --- - Transform2D matrix_global = _mode7_aspect_rotate(mode7_global_rotation, region_aspect); - Transform2D matrix_global_inv = matrix_global.affine_inverse(); - - // Per-scanline inverse solve for a single candidate row "p_dy": given a - // target point in region-local UV space, find "dest" such that the - // per-scanline transform (looked up at p_dy) maps dest -> the target. - // Same lookup _mode7_rebuild_scanline_texture() uses to bake the scanline - // table, evaluated here at full precision instead of the table's - // 1024-row resolution. - auto solve_dest_for_v = [&](const Vector2 &p_v, real_t p_dy, Vector2 &r_dest) -> bool { + // --- 3) The GLOBAL step (rotation about gp, then offset) as one affine map on + // region-local uv: v_global(u) = G*u + d. + Transform2D G = _mode7_aspect_rotate(mode7_global_rotation, region_aspect); + Vector2 gp = mode7_global_pivot; + Vector2 go = mode7_global_offset; + Transform2D G_inv = G.affine_inverse(); + Vector2 d = gp - G.basis_xform(gp) + G.basis_xform(go); // so v_global(u) = G*u + d + + // Per-row inverse solve for a single candidate row dy: given the "after-per-row" + // target u1 (region-local), find dest v such that v_global(M(r)(v-p(r))+p(r)+o(r)) = u1_global. + // v = M(r)^-1 * (u1 - p(r) - o(r)) + p(r). + auto solve_dest_for_v = [&](const Vector2 &p_u1, real_t p_dy, Vector2 &r_dest, bool &r_ok) { Transform2D scan_transform; Vector2 pivot; Color unused_modulate; _mode7_compute_scanline_data(p_dy, scan_transform, pivot, unused_modulate); Vector2 scan_offset = scan_transform.columns[2]; - // Strip translation to invert the 2x2 basis alone; affine_inverse() - // is a true general inverse (not assuming orthonormality), which - // matters once rotation/skew are non-zero. Transform2D matrix_transformed(scan_transform.columns[0], scan_transform.columns[1], Vector2()); if (Math::is_zero_approx(matrix_transformed.determinant())) { - // A degenerate override basis (zero scale or collinear columns) has no - // inverse; the row collapses and no source point maps through it. - r_dest = Vector2(p_v.x, p_dy); - return false; + // Degenerate row (zero scale / collinear columns): no inverse, no valid dest through it. + r_dest = Vector2(p_u1.x, p_dy); + r_ok = false; + return; } Transform2D matrix_transformed_inv = matrix_transformed.affine_inverse(); - - // Inverse of: target = matrix_transformed * (dest - pivot) + pivot + scan_offset - r_dest = matrix_transformed_inv.basis_xform(p_v - pivot - scan_offset) + pivot; - return true; + r_dest = matrix_transformed_inv.basis_xform(p_u1 - pivot - scan_offset) + pivot; + r_ok = true; }; - // Solves for "dest" given a full candidate target "p_v" (already in - // region-local UV space). Bisects on dy in [0,1] to find the row whose - // per-scanline transform is self-consistent (dest.y == dy). Reports - // whether a genuine root exists (sign change across [0,1]) via - // r_is_valid_root -- a "dest" that merely lands inside [0,1] after the - // no-sign-change clamp fallback is NOT a valid mapping and must not be - // mistaken for one by the wrap-candidate search in step 4. - auto solve_for_candidate = [&](const Vector2 &p_v, Vector2 &r_dest, bool &r_is_valid_root) { - Vector2 inter = matrix_global_inv.basis_xform(p_v - mode7_global_pivot - matrix_global.basis_xform(mode7_global_offset)) + mode7_global_pivot; - - real_t lo = 0.0f, hi = 1.0f; - Vector2 dest_lo, dest_hi; - const bool ok_lo = solve_dest_for_v(inter, lo, dest_lo); - const bool ok_hi = solve_dest_for_v(inter, hi, dest_hi); - if (!ok_lo || !ok_hi) { - r_dest = ok_lo ? dest_lo : dest_hi; - r_is_valid_root = false; - return; + // Returns true if the forward map of dest (at row root_row) actually equals target T, + // i.e. dest is a genuine preimage of T (not a degenerate fold root). With tiling the + // equality is modulo 1 (the shader wraps with fract), so the difference may be small + // or ~1 (across a tile boundary). + auto forward_maps_to = [&](const Vector2 &p_dest, real_t p_root_row, const Vector2 &p_T) -> bool { + Transform2D scan_transform; + Vector2 pivot; + Color unused_modulate; + _mode7_compute_scanline_data(p_root_row, scan_transform, pivot, unused_modulate); + Vector2 fwd = G.basis_xform(scan_transform.basis_xform(p_dest - pivot) + pivot + scan_transform.columns[2] - gp) + gp + G.basis_xform(go); + Vector2 fwd_full = Vector2(fwd.x * R.size.x + R.position.x, fwd.y * R.size.y + R.position.y); + real_t dx = Math::abs(fwd_full.x - p_T.x); + real_t dy = Math::abs(fwd_full.y - p_T.y); + real_t tol = 0.02f; + if (mode7_tiling) { + dx = Math::abs(dx - Math::round(dx)); // distance to the nearest integer + dx = Math::abs(dx - Math::round(dx)); } + return dx <= tol && dy <= tol; + }; - real_t residual_lo = dest_lo.y - lo; - real_t residual_hi = dest_hi.y - hi; - - Vector2 dest = dest_lo; - if (SIGN(residual_lo) != SIGN(residual_hi) || residual_lo == 0.0f || residual_hi == 0.0f) { - // Sign change: a genuine root exists in this interval. Standard - // bisection; ~40 iterations is comfortably enough for - // float/real_t precision on a unit interval. - for (int i = 0; i < 40; i++) { - real_t mid = (lo + hi) * 0.5f; - Vector2 dest_mid; - if (!solve_dest_for_v(inter, mid, dest_mid)) { - break; + // Resolves the dest v for one full-texture target T, or the zero vector if none is valid. + // - u1 = inverse-global of T (row independent). + // - find ALL self-consistent rows r of H(r) = v(r).y - r on [0,1] (grid scan of sign + // changes, then bisection-refine each bracket). There can be more than one for LERP/NONE. + // - keep the first root whose forward map actually equals T (forward_maps_to): this + // eliminates degenerate 180-degree-fold roots that are self-consistent (v.y == r) but + // map to the antipodal texel rather than T, and disambiguates multiple roots. + auto resolve_dest_for = [&](const Vector2 &p_T, bool &r_found) -> Vector2 { + r_found = false; + // Inverse of the global step: u2 = (T - R.xy)/R.zw ; u1 = G^-1*(u2 - d). + Vector2 u2 = Vector2((p_T.x - R.position.x) / R.size.x, (p_T.y - R.position.y) / R.size.y); + Vector2 u1 = G_inv.basis_xform(u2 - d); + + const int N = 256; // scan resolution across the row interval [0,1] + int prev_sign = 0; + int prev_i = -1; + real_t prev_absH = 1e30f; + Vector2 best_touch; + bool have_touch = false; + + for (int i = 0; i <= N; i++) { + real_t row = (real_t)i / (real_t)N; + Vector2 v_i; + bool ok_i = false; + solve_dest_for_v(u1, row, v_i, ok_i); + if (!ok_i) { + prev_sign = 0; // a degenerate row breaks any sign-change bracket + prev_i = -1; + continue; + } + real_t H_i = v_i.y - row; + real_t aH = Math::abs(H_i); + if (aH < 1e-3f && aH < prev_absH) { + best_touch = v_i; + have_touch = true; + prev_absH = aH; + } + int s_i = (H_i > 0.0f) ? 1 : (H_i < 0.0f ? -1 : 0); + if (prev_sign != 0 && s_i != 0 && s_i != prev_sign && prev_i >= 0) { + // A genuine root is bracketed in (prev_i, i]: refine it. + real_t r_lo = (real_t)prev_i / (real_t)N; + real_t r_hi = (real_t)i / (real_t)N; + Vector2 v_lo; + bool ok_lo = false; + solve_dest_for_v(u1, r_lo, v_lo, ok_lo); + for (int it = 0; it < 40; it++) { + real_t mid = (r_lo + r_hi) * 0.5f; + Vector2 v_mid; + bool ok_mid = false; + solve_dest_for_v(u1, mid, v_mid, ok_mid); + if (!ok_mid) { + break; + } + real_t H_lo = (ok_lo ? v_lo.y : 0.0f) - r_lo; + real_t H_mid = v_mid.y - mid; + if (H_lo * H_mid <= 0.0f) { + r_hi = mid; + } else { + r_lo = mid; + v_lo = v_mid; + ok_lo = true; + } } - real_t residual_mid = dest_mid.y - mid; - - if (SIGN(residual_mid) == SIGN(residual_lo)) { - lo = mid; - residual_lo = residual_mid; - } else { - hi = mid; - residual_hi = residual_mid; + real_t root_row = (r_lo + r_hi) * 0.5f; + bool ok_root = false; + Vector2 dest; + solve_dest_for_v(u1, root_row, dest, ok_root); + if (ok_root && dest.x >= 0.0f && dest.x <= 1.0f && dest.y >= 0.0f && dest.y <= 1.0f) { + // Validity gate: the forward map of this dest must equal the target T. + if (forward_maps_to(dest, root_row, p_T)) { + r_found = true; + return dest; + } } - dest = dest_mid; } - r_is_valid_root = true; - } else { - // No sign change: no row satisfies the self-consistency equation - // for this candidate -- it doesn't map anywhere onto the visible - // transformed image. The clamp-to-nearest-endpoint value is only - // useful as a last-resort fallback, never a real answer. - dest = (Math::abs(residual_lo) < Math::abs(residual_hi)) ? dest_lo : dest_hi; - r_is_valid_root = false; + prev_sign = s_i; + prev_i = i; + prev_absH = aH; } - r_dest = dest; - }; - - // --- 4) Resolve which tiled copy of source_full_uv is the correct one to - // feed the solver. Only matters when mode7_tiling is enabled: the - // region can drift arbitrarily far outside [0, tex_size] (that's the - // point of mode7_region_follow_target + tiling -- a seamlessly - // scrolling region), and the shader only wraps at the very end - // (fract(uv_full)), after all per-scanline/global math. Going - // backwards, one source point can correspond to several - // integer-shifted candidates in region-local space, but only some of - // those are real (self-consistent) roots at all. - // - // A single "nearest wrap to region center" guess isn't reliable once - // the region is large/off-center relative to the texture (a source - // point near one edge of a big region can need a different wrap than - // the region's own center) -- so instead this searches a small grid - // of wraps around that center guess, keeps only candidates that are - // BOTH a genuine root AND land inside [0,1]x[0,1], and among those - // picks the one whose unwrapped position is closest to the region - // center. If none qualify, falls back to the original center-nearest - // guess so there's always some answer. --- - Vector2 dest; - if (mode7_tiling) { - Vector2 region_center_full_uv = region_rect_norm.position + region_rect_norm.size * 0.5f; - Vector2 n_center = (region_center_full_uv - source_full_uv).round(); - - bool found_valid = false; - real_t best_dist = 0.0f; - Vector2 best_dest; - Vector2 fallback_dest; - bool have_fallback = false; - - const int SEARCH_RADIUS = 2; - for (int kx = -SEARCH_RADIUS; kx <= SEARCH_RADIUS; kx++) { - for (int ky = -SEARCH_RADIUS; ky <= SEARCH_RADIUS; ky++) { - Vector2 n = n_center + Vector2((real_t)kx, (real_t)ky); - Vector2 candidate_full_uv = source_full_uv + n; - Vector2 candidate_region_local = (candidate_full_uv - region_rect_norm.position) / region_rect_norm.size; - - Vector2 candidate_dest; - bool is_valid_root = false; - solve_for_candidate(candidate_region_local, candidate_dest, is_valid_root); - - // Keep the center-nearest candidate as a fallback regardless - // of validity, in case nothing in the grid qualifies. - if (n.is_equal_approx(n_center)) { - fallback_dest = candidate_dest; - have_fallback = true; - } - - bool in_range = candidate_dest.x >= 0.0f && candidate_dest.x <= 1.0f && - candidate_dest.y >= 0.0f && candidate_dest.y <= 1.0f; - if (!is_valid_root || !in_range) { - continue; - } - real_t dist = n.distance_squared_to(n_center); - if (!found_valid || dist < best_dist) { - found_valid = true; - best_dist = dist; - best_dest = candidate_dest; + // No sign-change root (or none passed the gate): fall back to a near-touch of H=0 + // (e.g. a fold), if it is inside the quad and maps to T. + if (have_touch) { + Vector2 dest = best_touch; + if (dest.x >= 0.0f && dest.x <= 1.0f && dest.y >= 0.0f && dest.y <= 1.0f) { + if (forward_maps_to(dest, dest.y, p_T)) { + r_found = true; + return dest; } } } + return Vector2(); // this target is not reached by the forward map at a valid dest + }; - dest = found_valid ? best_dest : (have_fallback ? fallback_dest : Vector2()); + // --- 4) Resolve the destination for the input point. + // The input point source_full_uv is already an exact location in the infinitely- + // tilled texture plane: its integer part encodes WHICH tile copy it is in, and its + // fractional part encodes WHERE within that copy. resolve_dest_for() inverts the + // full forward map (per-row affine, then global step, then the tiling wrap) for + // that exact target, so the destination it returns already lands on the correct + // tile. There is no ambiguity to resolve here and no need to re-anchor the + // candidate on the region center -- doing so (as before) would pick a different + // tile copy than the one the input point actually belongs to. + Vector2 dest; + bool have_dest = false; - // DEBUG: print_line(vformat("n_center=%s found_valid=%s dest=%s", n_center, found_valid ? "true" : "false", dest)); - } else { - // No tiling: source_full_uv is unambiguous, so region-local mapping - // is a single direct divide -- no wraparound ambiguity to resolve. - Vector2 source_region_local = (source_full_uv - region_rect_norm.position) / region_rect_norm.size; - bool unused_is_valid_root = false; - solve_for_candidate(source_region_local, dest, unused_is_valid_root); + bool found = false; + Vector2 dv = resolve_dest_for(source_full_uv, found); + if (found) { + dest = dv; + have_dest = true; } - // --- 5) dest (region-local UV, the resolved screen-space coordinate) -> - // full-texture UV -> texture-pixel space -> back through the actual, - // region-cropped dst_rect/src_rect (forward direction this time) -> - // back through this node's own Transform2D, landing in the same - // space p_point was given in. mode7_tiling wrap is intentionally NOT - // re-applied here: dest is already the resolved, unambiguous - // screen-space answer -- wrapping only applies to the shader's own - // forward sampling step. This step deliberately uses the REAL, - // region-cropped rects (unlike step 1), because that's what the - // renderer actually draws: the visible sprite is only as big as the - // cropped region's quad, not the full texture. --- + // --- 5) dest (region-local [0,1]^2) -> full-texture UV -> local drawing space -> + // parent-local space. Uses the REAL, region-cropped rects (what the renderer draws). Rect2 cropped_src_rect, cropped_dst_rect; bool unused_filter_clip = false; _get_rects(cropped_src_rect, cropped_dst_rect, unused_filter_clip); if (cropped_src_rect.size.x == 0.0f || cropped_src_rect.size.y == 0.0f) { - // Same degeneracy the region_px guard above handles: a zero-size region - // leaves no quad to map back into, so there is no meaningful answer. + // Degenerate region: no quad to map into. + if (p_visible_area_only) { + return Variant(); + } return p_point; } - Vector2 dest_full_uv = dest * region_rect_norm.size + region_rect_norm.position; + Vector2 dest_full_uv = Vector2(dest.x * R.size.x + R.position.x, dest.y * R.size.y + R.position.y); Vector2 tex_point_out = dest_full_uv * tex_size; Vector2 local_point_out = cropped_dst_rect.position + (tex_point_out - cropped_src_rect.position) * (cropped_dst_rect.size / cropped_src_rect.size); + Vector2 result = get_transform().xform(local_point_out); - // DEBUG: print_line(vformat("dest_full_uv=%s tex_point_out=%s cropped_src_rect=%s cropped_dst_rect=%s", - // dest_full_uv, tex_point_out, cropped_src_rect, cropped_dst_rect)); - - return get_transform().xform(local_point_out); + // visible_area_only: return null unless the point has a real destination that is + // actually drawn (inside the region quad, i.e. have_dest is set). The point's + // position itself is always the correct (deterministic) transformed location. + if (p_visible_area_only && !have_dest) { + return Variant(); + } + return result; } void Mode7Sprite2D::set_mode7_tiling(bool p_tiling) { @@ -1245,7 +1235,7 @@ void Mode7Sprite2D::_bind_methods() { ClassDB::bind_method(D_METHOD("set_mode7_saved_material", "material"), &Mode7Sprite2D::set_mode7_saved_material); ClassDB::bind_method(D_METHOD("get_mode7_saved_material"), &Mode7Sprite2D::get_mode7_saved_material); - ClassDB::bind_method(D_METHOD("mode7_transform_point", "point"), &Mode7Sprite2D::mode7_transform_point); + ClassDB::bind_method(D_METHOD("mode7_transform_point", "point", "visible_area_only"), &Mode7Sprite2D::mode7_transform_point, DEFVAL(false)); // Properties (exposed in the Inspector) ----------------------------------- diff --git a/scene/2d/mode7_sprite_2d.h b/scene/2d/mode7_sprite_2d.h index 496153084d1..03779845671 100644 --- a/scene/2d/mode7_sprite_2d.h +++ b/scene/2d/mode7_sprite_2d.h @@ -126,9 +126,13 @@ class Mode7Sprite2D : public Sprite2D { void force_update_follow_cache(); /// Takes a point in this node's parent-local space, on the undistorted source artwork, - /// @return The point in the same space after the per-scanline global Mode 7 transformation that the shader's fragment() function applies. + /// @param p_visible_area_only If true, return null when the point has no drawn destination + /// (e.g. it lies in the area the Mode 7 transform does not cover). When false (default), + /// always return the correct transformed location even if that area is not currently drawn. + /// @return The point in the same space after the per-scanline global Mode 7 transformation + /// that the shader's fragment() function applies (or null per p_visible_area_only). /// This is the CPU-side equivalent of "where does this point move to?" - Vector2 mode7_transform_point(const Vector2 &p_point) const; + Variant mode7_transform_point(const Vector2 &p_point, bool p_visible_area_only = false) const; Mode7Sprite2D();