diff --git a/doc/classes/Mode7Sprite2D.xml b/doc/classes/Mode7Sprite2D.xml index 97a1673f8c..396b781436 100644 --- a/doc/classes/Mode7Sprite2D.xml +++ b/doc/classes/Mode7Sprite2D.xml @@ -32,6 +32,24 @@ 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. + + 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 e1e0f65edb..493e1ab367 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" @@ -245,201 +246,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 +278,413 @@ 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()); +} + +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; + } +} + +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. + // + // 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) { + return p_point; + } + + 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; + } + + // --- 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); + + Vector2 local_point = get_transform().affine_inverse().xform(p_point); + 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; + + // --- 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) { + region_px.size = tex_size; + } + Rect2 R(region_px.position / tex_size, region_px.size / tex_size); // REGION_RECT + + // 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; + } + + // --- 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]; + Transform2D matrix_transformed(scan_transform.columns[0], scan_transform.columns[1], Vector2()); + if (Math::is_zero_approx(matrix_transformed.determinant())) { + // 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(); + r_dest = matrix_transformed_inv.basis_xform(p_u1 - pivot - scan_offset) + pivot; + r_ok = true; + }; + + // 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; + }; + + // 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 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; + } + } + } + prev_sign = s_i; + prev_i = i; + prev_absH = aH; + } + + // 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 + }; + + // --- 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; + + bool found = false; + Vector2 dv = resolve_dest_for(source_full_uv, found); + if (found) { + dest = dv; + have_dest = true; + } + + // --- 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) { + // Degenerate region: no quad to map into. + if (p_visible_area_only) { + return Variant(); + } + return p_point; + } + + 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); + + // 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) { if (mode7_tiling == p_tiling) { return; @@ -998,6 +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", "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 373c0f580c..0377984567 100644 --- a/scene/2d/mode7_sprite_2d.h +++ b/scene/2d/mode7_sprite_2d.h @@ -125,6 +125,15 @@ 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(); + /// Takes a point in this node's parent-local space, on the undistorted source artwork, + /// @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?" + Variant mode7_transform_point(const Vector2 &p_point, bool p_visible_area_only = false) const; + Mode7Sprite2D(); private: @@ -144,6 +153,33 @@ 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); + + /// 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 — diff --git a/scene/2d/sprite_2d.h b/scene/2d/sprite_2d.h index 988dc4562f..d52acb4748 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;