diff --git a/Libraries/LibGfx/BSPTree.cpp b/Libraries/LibGfx/BSPTree.cpp new file mode 100644 index 0000000000000..20a3e8e6697c2 --- /dev/null +++ b/Libraries/LibGfx/BSPTree.cpp @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2026, Tim Ledbetter + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include + +namespace Gfx { + +// Vertices closer to a splitting plane than this distance count as lying on it, giving the plane a thickness that +// absorbs floating-point noise from the projection. +static constexpr float on_plane_threshold = 0.05f; + +Vector map_rect_through_projection(FloatMatrix4x4 const& matrix, FloatRect const& rect) +{ + // Projecting a point divides it by w, which only works in front of the eye plane where w is positive. + // Edges crossing behind the eye are clipped at this small positive w so the divide stays finite. + static constexpr float minimum_projection_w = 0.00001f; + + Array corners = { + matrix * FloatVector4 { rect.left(), rect.top(), 0, 1 }, + matrix * FloatVector4 { rect.right(), rect.top(), 0, 1 }, + matrix * FloatVector4 { rect.right(), rect.bottom(), 0, 1 }, + matrix * FloatVector4 { rect.left(), rect.bottom(), 0, 1 }, + }; + + Vector result; + auto append_projected = [&](FloatVector4 const& vertex) { + result.append({ vertex.x() / vertex.w(), vertex.y() / vertex.w(), vertex.z() / vertex.w() }); + }; + for (size_t i = 0; i < corners.size(); ++i) { + auto const& current = corners[i]; + auto const& next = corners[(i + 1) % corners.size()]; + bool current_in_front_of_eye = current.w() > minimum_projection_w; + bool next_in_front_of_eye = next.w() > minimum_projection_w; + if (current_in_front_of_eye) + append_projected(current); + if (current_in_front_of_eye != next_in_front_of_eye) { + auto t = (minimum_projection_w - current.w()) / (next.w() - current.w()); + append_projected(current + (next - current) * t); + } + } + return result; +} + +static Optional polygon_normal(ReadonlySpan vertices) +{ + if (vertices.size() < 3) + return {}; + FloatVector3 normal { 0, 0, 0 }; + for (size_t i = 1; i < vertices.size() - 1; ++i) + normal += (vertices[i] - vertices[0]).cross(vertices[i + 1] - vertices[0]); + auto length = normal.length(); + // Returns no value for polygons that enclose no area, as they do not define a plane. + if (length == 0) + return {}; + return normal / length; +} + +namespace { + +struct PartitionedPolygon { + BSPPolygon polygon; + FloatVector3 plane_normal; + float plane_distance { 0 }; +}; + +constexpr size_t no_bsp_node = NumericLimits::max(); + +struct BSPTreeNode { + FloatVector3 plane_normal; + float plane_distance { 0 }; + Vector coplanar_polygons; + size_t front { no_bsp_node }; + size_t back { no_bsp_node }; +}; + +// The polygons of a subtree that has not been built yet, and the parent slot that will reference its node. +struct PendingSubtree { + Vector polygons; + size_t parent { no_bsp_node }; + bool is_front_child { false }; +}; + +struct PolygonSplit { + Optional front_piece; + Optional back_piece; +}; + +} + +static PolygonSplit split_polygon(PartitionedPolygon polygon, ReadonlySpan vertex_distances) +{ + Vector front_vertices; + Vector back_vertices; + auto const& vertices = polygon.polygon.vertices; + for (size_t i = 0; i < vertices.size(); ++i) { + size_t next_index = (i + 1) % vertices.size(); + auto current_distance = vertex_distances[i]; + auto next_distance = vertex_distances[next_index]; + if (current_distance >= -on_plane_threshold) + front_vertices.append(vertices[i]); + if (current_distance <= on_plane_threshold) + back_vertices.append(vertices[i]); + bool edge_crosses_plane = (current_distance > on_plane_threshold && next_distance < -on_plane_threshold) + || (current_distance < -on_plane_threshold && next_distance > on_plane_threshold); + if (edge_crosses_plane) { + auto t = current_distance / (current_distance - next_distance); + auto intersection = vertices[i] + (vertices[next_index] - vertices[i]) * t; + front_vertices.append(intersection); + back_vertices.append(intersection); + } + } + + auto make_piece = [&](Vector piece_vertices) -> Optional { + if (piece_vertices.size() < 3) + return {}; + return PartitionedPolygon { + BSPPolygon { move(piece_vertices), polygon.polygon.plane_index, true }, + polygon.plane_normal, + polygon.plane_distance, + }; + }; + return { make_piece(move(front_vertices)), make_piece(move(back_vertices)) }; +} + +static Vector build_bsp_tree(Vector polygons) +{ + Vector nodes; + Vector pending_subtrees; + if (!polygons.is_empty()) + pending_subtrees.append({ move(polygons), no_bsp_node, false }); + + Vector vertex_distances; + while (!pending_subtrees.is_empty()) { + auto subtree = pending_subtrees.take_last(); + auto node_index = nodes.size(); + if (subtree.parent != no_bsp_node) { + if (subtree.is_front_child) { + nodes[subtree.parent].front = node_index; + } else { + nodes[subtree.parent].back = node_index; + } + } + + auto splitter_index = subtree.polygons.size() / 2; + auto plane_normal = subtree.polygons[splitter_index].plane_normal; + auto plane_distance = subtree.polygons[splitter_index].plane_distance; + + Vector coplanar_polygons; + Vector front_list; + Vector back_list; + for (size_t polygon_index = 0; polygon_index < subtree.polygons.size(); ++polygon_index) { + auto& polygon = subtree.polygons[polygon_index]; + if (polygon_index == splitter_index) { + coplanar_polygons.append(move(polygon.polygon)); + continue; + } + vertex_distances.clear_with_capacity(); + size_t front_count = 0; + size_t back_count = 0; + for (auto const& vertex : polygon.polygon.vertices) { + auto distance = plane_normal.dot(vertex) - plane_distance; + vertex_distances.append(distance); + if (distance > on_plane_threshold) { + ++front_count; + } else if (distance < -on_plane_threshold) { + ++back_count; + } + } + + if (front_count == 0 && back_count == 0) { + coplanar_polygons.append(move(polygon.polygon)); + } else if (back_count == 0) { + front_list.append(move(polygon)); + } else if (front_count == 0) { + back_list.append(move(polygon)); + } else { + auto [front_piece, back_piece] = split_polygon(move(polygon), vertex_distances); + if (front_piece.has_value()) + front_list.append(front_piece.release_value()); + if (back_piece.has_value()) + back_list.append(back_piece.release_value()); + } + } + nodes.append({ plane_normal, plane_distance, move(coplanar_polygons), no_bsp_node, no_bsp_node }); + + if (!front_list.is_empty()) + pending_subtrees.append({ move(front_list), node_index, true }); + if (!back_list.is_empty()) + pending_subtrees.append({ move(back_list), node_index, false }); + } + return nodes; +} + +static Vector collect_back_to_front(Vector nodes) +{ + Vector ordered; + if (nodes.is_empty()) + return ordered; + + size_t polygon_count = 0; + for (auto const& node : nodes) + polygon_count += node.coplanar_polygons.size(); + ordered.ensure_capacity(polygon_count); + + struct TraversalStep { + size_t node_index { 0 }; + bool ready_to_emit { false }; + }; + Vector traversal_stack; + traversal_stack.append({ 0, false }); + while (!traversal_stack.is_empty()) { + auto step = traversal_stack.take_last(); + auto& node = nodes[step.node_index]; + // The subtree on the side of the plane the viewer is on paints last. Coplanar polygons paint in their stored + // paint order regardless of which way the plane faces. + auto far_subtree = node.plane_normal.z() > 0 ? node.back : node.front; + auto near_subtree = node.plane_normal.z() > 0 ? node.front : node.back; + if (!step.ready_to_emit) { + traversal_stack.append({ step.node_index, true }); + if (far_subtree != no_bsp_node) + traversal_stack.append({ far_subtree, false }); + continue; + } + for (auto& polygon : node.coplanar_polygons) + ordered.unchecked_append(move(polygon)); + if (near_subtree != no_bsp_node) + traversal_stack.append({ near_subtree, false }); + } + return ordered; +} + +static bool all_planes_are_parallel(ReadonlySpan polygons) +{ + // The cross product of two unit normals has the sine of the angle between the planes as its length. + // Below a microradian of tilt the planes are treated as parallel. + static constexpr float maximum_parallel_cross_length_squared = 1e-12f; + auto const& first_normal = polygons.first().plane_normal; + for (auto const& polygon : polygons.slice(1)) { + auto cross = polygon.plane_normal.cross(first_normal); + if (cross.dot(cross) > maximum_parallel_cross_length_squared) + return false; + } + return true; +} + +static Vector sort_parallel_polygons_back_to_front(Vector polygons) +{ + // Fast path for parallel planes, where no splitting is needed. + + auto axis = polygons.first().plane_normal; + if (axis.z() < 0) + axis = -axis; + + struct DepthOrderedPolygon { + float depth { 0 }; + size_t input_index { 0 }; + }; + Vector order; + order.ensure_capacity(polygons.size()); + for (size_t i = 0; i < polygons.size(); ++i) { + auto depth = polygons[i].plane_normal.dot(axis) > 0 ? polygons[i].plane_distance : -polygons[i].plane_distance; + order.unchecked_append({ depth, i }); + } + quick_sort(order, [](DepthOrderedPolygon const& a, DepthOrderedPolygon const& b) { + if (a.depth != b.depth) + return a.depth < b.depth; + return a.input_index < b.input_index; + }); + + Vector ordered; + ordered.ensure_capacity(order.size()); + for (auto const& entry : order) + ordered.unchecked_append(move(polygons[entry.input_index].polygon)); + return ordered; +} + +Vector split_and_sort_polygons_back_to_front(Vector polygons) +{ + Vector partitioned; + partitioned.ensure_capacity(polygons.size()); + for (auto& polygon : polygons) { + auto normal = polygon_normal(polygon.vertices); + if (!normal.has_value()) + continue; + auto distance = normal->dot(polygon.vertices.first()); + partitioned.unchecked_append({ move(polygon), *normal, distance }); + } + + if (!partitioned.is_empty() && all_planes_are_parallel(partitioned)) + return sort_parallel_polygons_back_to_front(move(partitioned)); + + return collect_back_to_front(build_bsp_tree(move(partitioned))); +} + +} diff --git a/Libraries/LibGfx/BSPTree.h b/Libraries/LibGfx/BSPTree.h new file mode 100644 index 0000000000000..b8d65e8d32cc9 --- /dev/null +++ b/Libraries/LibGfx/BSPTree.h @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Tim Ledbetter + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include + +namespace Gfx { + +// A convex polygon in the shared post-projection space of a three-dimensional scene, where x and y +// are surface coordinates and the positive z-axis points toward the viewer. The plane index +// identifies the plane the polygon was built from and is preserved on pieces produced by splitting. +struct BSPPolygon { + Vector vertices; + size_t plane_index { 0 }; + bool clipped { false }; +}; + +Vector map_rect_through_projection(FloatMatrix4x4 const&, FloatRect const&); + +Vector split_and_sort_polygons_back_to_front(Vector); + +} diff --git a/Libraries/LibGfx/CMakeLists.txt b/Libraries/LibGfx/CMakeLists.txt index a30ff214eb10d..67835103a5392 100644 --- a/Libraries/LibGfx/CMakeLists.txt +++ b/Libraries/LibGfx/CMakeLists.txt @@ -1,5 +1,6 @@ set(SOURCES AffineTransform.cpp + BSPTree.cpp Bitmap.cpp BitmapExport.cpp BitmapSequence.cpp diff --git a/Libraries/LibWeb/CMakeLists.txt b/Libraries/LibWeb/CMakeLists.txt index 48278727aaa4a..94909eec83890 100644 --- a/Libraries/LibWeb/CMakeLists.txt +++ b/Libraries/LibWeb/CMakeLists.txt @@ -897,6 +897,7 @@ set(SOURCES Painting/CanvasPaintable.cpp Painting/CheckBoxPaintable.cpp Painting/ChromeWidget.cpp + Painting/DepthSortedReplayPlan.cpp Painting/DisplayList.cpp Painting/DisplayListCommand.cpp Painting/DisplayListDamage.cpp diff --git a/Libraries/LibWeb/Painting/AccumulatedVisualContext.cpp b/Libraries/LibWeb/Painting/AccumulatedVisualContext.cpp index 28e809153b23b..ac40a7ec352ac 100644 --- a/Libraries/LibWeb/Painting/AccumulatedVisualContext.cpp +++ b/Libraries/LibWeb/Painting/AccumulatedVisualContext.cpp @@ -142,10 +142,9 @@ static TransformData compute_svg_viewport_transform_data(Paintable const& viewpo .translate(viewport_box.absolute_rect().location().to_type()) .multiply(viewbox_transform); return TransformData { - scale_matrix_for_device_pixels(matrix.to_matrix(), static_cast(pixel_ratio)), - { 0.f, 0.f }, - false, - TransformDataRole::SvgViewportTransform, + .matrix = scale_matrix_for_device_pixels(matrix.to_matrix(), static_cast(pixel_ratio)), + .origin = { 0.f, 0.f }, + .role = TransformDataRole::SvgViewportTransform, }; } @@ -407,6 +406,7 @@ AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaint VisualContextIndex absolute_position_plane_root; VisualContextIndex fixed_position_plane_root; bool flattens_inherited_transform { false }; + Optional sorting_context_root {}; }; auto build_paintable_box = [&](Paintable& paintable_box, DescendantVisualContexts inherited_contexts, bool may_be_root_element) -> DescendantVisualContexts { @@ -516,6 +516,7 @@ AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaint bool appended_transform_node = false; if (transform_data.has_value()) { transform_data->flattens_inherited_transform = flattens_inherited_transform; + transform_data->sorting_context_root_index = inherited_contexts.sorting_context_root; paintable_box.set_has_non_invertible_css_transform(!transform_data->matrix.is_invertible()); own_state = append_node(own_state, *transform_data); appended_transform_node = true; @@ -559,6 +560,26 @@ AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaint bool inherited_flatten_still_pending = flattens_inherited_transform && !appended_transform_node && !appended_backface_marker; auto descendants_flatten_inherited_transform = invisible_to_3d_rendering_contexts ? flattens_inherited_transform : (!establishes_or_extends_3d_rendering_context || inherited_flatten_still_pending); + // https://drafts.csswg.org/css-transforms-2/#3d-rendering-contexts + // A 3D rendering context is established by a transformable element whose used value for transform-style + // is preserve-3d and which itself is not part of a 3D rendering context. An element that establishes a + // 3D rendering context also participates in that context. + // NB: Every preserve-3d element renders into its own plane, so one without a transform of its own + // appends an identity transform node to provide that plane. The establishing element's own state + // serves as the context's root; replay sorts the content recorded under it as the context's z=0 + // plane alongside the planes of the participants, whose transform nodes reference the root. + auto sorting_context_root_for_descendants = inherited_contexts.sorting_context_root; + if (!invisible_to_3d_rendering_contexts) { + if (!establishes_or_extends_3d_rendering_context) { + sorting_context_root_for_descendants = {}; + } else { + if (!appended_transform_node) + own_state = append_node(own_state, TransformData { .matrix = Gfx::FloatMatrix4x4::identity(), .origin = {}, .sorting_context_root_index = sorting_context_root_for_descendants, .flattens_inherited_transform = flattens_inherited_transform, .synthetic_plane = true }); + if (!sorting_context_root_for_descendants.has_value()) + sorting_context_root_for_descendants = own_state; + } + } + if (layout_node.clip().is_rect()) { if (auto css_clip = compute_css_clip_data(paintable_box, converter); css_clip.has_value()) append_to_own_and_positioned_descendant_contexts(css_clip.value()); @@ -687,6 +708,7 @@ AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaint absolute_position_plane_root, fixed_position_plane_root, descendants_flatten_inherited_transform, + sorting_context_root_for_descendants, }; }; @@ -872,9 +894,17 @@ bool update_accumulated_visual_context_values(ViewportPaintable& viewport_painta found_svg_viewport_transform = true; continue; } + // A synthetic plane node has no computed transform behind it. It stays as-is unless the element + // gained a real transform, which changes the structure the node was built for. + if (transform_data->synthetic_plane) { + if (transform.has_value()) + return false; + continue; + } if (!transform.has_value()) return false; transform->flattens_inherited_transform = transform_data->flattens_inherited_transform; + transform->sorting_context_root_index = transform_data->sorting_context_root_index; *transform_data = *transform; found_css_transform = true; } else if (auto* effects_data = node.data.get_pointer()) { @@ -1173,6 +1203,77 @@ Optional AccumulatedVisualContextTree::transform_point_for_hit_ return point; } +VisualContextIndex SortingContexts::outermost_context_of(VisualContextIndex context) const +{ + for (;;) { + auto link = links.get(context.value()); + if (!link.has_value() || link->parent_context == NO_SORTING_CONTEXT) + return context; + context = link->parent_context; + } +} + +SortingContexts AccumulatedVisualContextTree::resolve_sorting_contexts() const +{ + auto node_count = m_nodes.size(); + + Vector is_sorting_context_root; + is_sorting_context_root.resize(node_count); + bool has_sorting_context_roots = false; + for (auto const& node : m_nodes) { + if (auto const* transform = node.data.get_pointer(); transform && transform->sorting_context_root_index.has_value()) { + is_sorting_context_root[transform->sorting_context_root_index->value()] = true; + has_sorting_context_roots = true; + } + } + if (!has_sorting_context_roots) + return {}; + + // Roots always precede their contexts' nodes, so a single forward walk resolves every node. + SortingContexts contexts; + contexts.leaf_by_node.ensure_capacity(node_count); + contexts.context_by_node.ensure_capacity(node_count); + for (size_t i = 0; i < node_count; ++i) { + auto parent = m_nodes[i].parent_index.value(); + auto inherited_leaf = i == 0 ? NO_SORTING_CONTEXT : contexts.leaf_by_node[parent]; + auto inherited_context = i == 0 ? NO_SORTING_CONTEXT : contexts.context_by_node[parent]; + auto const* transform = m_nodes[i].data.get_pointer(); + if (transform && transform->sorting_context_root_index.has_value()) { + contexts.leaf_by_node.unchecked_append(VisualContextIndex { i }); + contexts.context_by_node.unchecked_append(*transform->sorting_context_root_index); + } else if (is_sorting_context_root[i]) { + contexts.links.set(i, { inherited_context, inherited_leaf }); + contexts.leaf_by_node.unchecked_append(VisualContextIndex { i }); + contexts.context_by_node.unchecked_append(VisualContextIndex { i }); + } else { + contexts.leaf_by_node.unchecked_append(inherited_leaf); + contexts.context_by_node.unchecked_append(inherited_context); + } + } + return contexts; +} + +Optional AccumulatedVisualContextTree::plane_depth_at_point_for_hit_test(VisualContextIndex plane_node_index, Gfx::FloatPoint screen_point, ScrollStateSnapshot const& scroll_state) const +{ + auto chain = build_ancestor_chain(plane_node_index); + auto accumulated_matrix = Gfx::FloatMatrix4x4::identity(); + for (size_t i = chain.size(); i > 0; --i) { + auto node_index = VisualContextIndex { chain[i - 1] }; + auto local = local_spatial_matrix(m_nodes[node_index.value()], node_index, scroll_state); + accumulated_matrix = (local.flattens_inherited_transform ? Gfx::flattened(accumulated_matrix) : accumulated_matrix) * local.matrix; + } + + auto inverse = accumulated_matrix.inverse(); + if (!inverse.has_value()) + return {}; + + auto const& matrix = *inverse; + auto depth = -(screen_point.x() * matrix[2, 0] + screen_point.y() * matrix[2, 1] + matrix[2, 3]) / matrix[2, 2]; + if (!isfinite(depth)) + return {}; + return depth; +} + Gfx::FloatPoint AccumulatedVisualContextTree::inverse_transform_point(VisualContextIndex index, Gfx::FloatPoint screen_point) const { auto chain = build_ancestor_chain(index); @@ -1442,8 +1543,10 @@ ErrorOr encode(Encoder& encoder, Web::Painting::TransformData const& data) { TRY(encoder.encode(data.matrix)); TRY(encoder.encode(data.origin)); + TRY(encoder.encode(data.sorting_context_root_index)); TRY(encoder.encode(data.flattens_inherited_transform)); TRY(encoder.encode(data.role)); + TRY(encoder.encode(data.synthetic_plane)); return {}; } @@ -1453,8 +1556,10 @@ ErrorOr decode(Decoder& decoder) return Web::Painting::TransformData { .matrix = TRY(decoder.decode()), .origin = TRY(decoder.decode()), + .sorting_context_root_index = TRY(decoder.decode>()), .flattens_inherited_transform = TRY(decoder.decode()), .role = TRY(decoder.decode()), + .synthetic_plane = TRY(decoder.decode()), }; } @@ -1625,6 +1730,13 @@ ErrorOr decode(Decoder& decoder) return Error::from_string_literal("IPC decode: AccumulatedVisualContextTree missing visual viewport node"); if (!nodes[Web::Painting::VISUAL_VIEWPORT_NODE_INDEX.value()].data.has()) return Error::from_string_literal("IPC decode: AccumulatedVisualContextTree visual viewport node is not a transform"); + for (size_t i = 0; i < nodes.size(); ++i) { + if (nodes[i].parent_index.value() >= max(i, static_cast(1))) + return Error::from_string_literal("IPC decode: AccumulatedVisualContextTree node parent does not precede it"); + auto const* transform = nodes[i].data.get_pointer(); + if (transform && transform->sorting_context_root_index.has_value() && transform->sorting_context_root_index->value() >= i) + return Error::from_string_literal("IPC decode: AccumulatedVisualContextTree sorting context root does not precede its participant"); + } return Web::Painting::AccumulatedVisualContextTree { version, move(nodes), root_is_visual_viewport }; } diff --git a/Libraries/LibWeb/Painting/AccumulatedVisualContext.h b/Libraries/LibWeb/Painting/AccumulatedVisualContext.h index 66cc19dc2f53b..37a01dac50bd5 100644 --- a/Libraries/LibWeb/Painting/AccumulatedVisualContext.h +++ b/Libraries/LibWeb/Painting/AccumulatedVisualContext.h @@ -7,6 +7,8 @@ #pragma once #include +#include +#include #include #include #include @@ -61,8 +63,10 @@ enum class TransformDataRole : u8 { struct TransformData { Gfx::FloatMatrix4x4 matrix; Gfx::FloatPoint origin; + Optional sorting_context_root_index {}; bool flattens_inherited_transform { false }; TransformDataRole role { TransformDataRole::CssTransform }; + bool synthetic_plane { false }; Gfx::FloatMatrix4x4 matrix_including_origin() const; }; @@ -138,6 +142,26 @@ struct AccumulatedVisualContextNode { bool has_empty_effective_clip { false }; }; +// Marks a visual context node whose content belongs to no 3D rendering context. +static constexpr VisualContextIndex NO_SORTING_CONTEXT { NumericLimits::max() }; + +// The plane and 3D rendering context that an established context's own plane renders into. +struct SortingContextLink { + VisualContextIndex parent_context; + VisualContextIndex parent_leaf; +}; + +// Per-node 3D rendering context membership: the plane each node's content renders into and the context that +// sorts that plane. A tree without 3D rendering contexts resolves to empty per-node vectors. +struct SortingContexts { + HashMap links; + Vector leaf_by_node; + Vector context_by_node; + + bool is_empty() const { return leaf_by_node.is_empty(); } + VisualContextIndex outermost_context_of(VisualContextIndex) const; +}; + class AccumulatedVisualContextTree { public: enum class IncludeVisualViewportTransform { @@ -177,6 +201,8 @@ class AccumulatedVisualContextTree { AccumulatedVisualContextNode& node_at(VisualContextIndex index) { return m_nodes[index.value()]; } ReadonlySpan nodes() const { return m_nodes.span(); } + SortingContexts resolve_sorting_contexts() const; + Optional plane_depth_at_point_for_hit_test(VisualContextIndex plane_node_index, Gfx::FloatPoint, ScrollStateSnapshot const&) const; Optional transform_point_for_hit_test(VisualContextIndex, Gfx::FloatPoint, ScrollStateSnapshot const&, ClipBehavior = ClipBehavior::Respect) const; Gfx::FloatPoint inverse_transform_point(VisualContextIndex, Gfx::FloatPoint) const; Gfx::FloatRect transform_rect_to_viewport(VisualContextIndex, Gfx::FloatRect const&, ScrollStateSnapshot const&, IncludeVisualViewportTransform = IncludeVisualViewportTransform::Yes) const; diff --git a/Libraries/LibWeb/Painting/DepthSortedReplayPlan.cpp b/Libraries/LibWeb/Painting/DepthSortedReplayPlan.cpp new file mode 100644 index 0000000000000..19274973f06b2 --- /dev/null +++ b/Libraries/LibWeb/Painting/DepthSortedReplayPlan.cpp @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026, Tim Ledbetter + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include + +namespace Web::Painting { + +namespace { + +struct LeafBounds { + VisualContextIndex leaf; + Gfx::FloatRect bounds; + bool unbounded { false }; +}; + +struct CommandChunk { + u32 offset { 0 }; + u32 size { 0 }; + VisualContextIndex leaf; + VisualContextIndex context; + Vector bounds_by_level; +}; + +struct ChunkPlacement { + VisualContextIndex child_context; + VisualContextIndex leaf; +}; + +struct DepthSortedPlanBuilder { + SortingContexts const& contexts; + ReadonlySpan transform_palette; + Vector steps; + + void emit_chunks(ReadonlySpan chunks, VisualContextIndex enclosing_context); + void sort_and_emit_context(ReadonlySpan chunks, VisualContextIndex sorting_context); +}; + +} + +static Vector partition_commands_into_plane_chunks( + ReadonlyBytes commands, + SortingContexts const& contexts, + ReadonlySpan transform_palette, + ReadonlySpan nearest_spatial_node, + ReadonlySpan backface_culled) +{ + struct LeafMapping { + VisualContextIndex leaf; + Optional to_leaf; + bool unbounded { false }; + }; + + struct LeafMappingsKey { + VisualContextIndex leaf; + VisualContextIndex context; + VisualContextIndex spatial_node; + bool operator==(LeafMappingsKey const&) const = default; + }; + + Vector mappings; + Optional mappings_key; + auto ensure_mappings = [&](LeafMappingsKey key) { + if (mappings_key == key) + return; + mappings_key = key; + mappings.clear_with_capacity(); + auto leaf = key.leaf; + auto sorting_context = key.context; + while (sorting_context != NO_SORTING_CONTEXT) { + if (key.spatial_node == leaf) { + mappings.append({ leaf, {} }); + } else if (auto inverse = transform_palette[leaf.value()].inverse(); inverse.has_value()) { + mappings.append({ leaf, *inverse * transform_palette[key.spatial_node.value()] }); + } else { + mappings.append({ leaf, {}, true }); + } + auto link = contexts.links.get(sorting_context.value()); + if (!link.has_value()) + break; + leaf = link->parent_leaf; + sorting_context = link->parent_context; + } + }; + + auto bounds_of_mapped_rect = [](Gfx::FloatMatrix4x4 const& matrix, Gfx::FloatRect const& rect) { + Gfx::FloatBoundingBox bounding_box; + for (auto const& vertex : Gfx::map_rect_through_projection(matrix, rect)) + bounding_box.add_point(vertex.x(), vertex.y()); + return bounding_box.to_rect(); + }; + + Vector chunks; + DisplayList::for_each_command_header(commands, [&](DisplayListCommandHeader const& header, ReadonlyBytes payload) { + auto offset = static_cast(payload.data() - commands.data() - sizeof(DisplayListCommandHeader)); + auto size = static_cast(sizeof(DisplayListCommandHeader) + header.payload_size); + auto leaf = contexts.leaf_by_node[header.context_index.value()]; + auto sorting_context = contexts.context_by_node[header.context_index.value()]; + if (chunks.is_empty() || chunks.last().leaf != leaf || chunks.last().context != sorting_context) + chunks.append({ offset, size, leaf, sorting_context, {} }); + else + chunks.last().size += size; + + if (!header.has_bounding_rect || header.is_clip || sorting_context == NO_SORTING_CONTEXT || backface_culled[header.context_index.value()]) + return; + ensure_mappings({ leaf, sorting_context, nearest_spatial_node[header.context_index.value()] }); + auto rect = header.bounding_rect.to_type(); + auto& level_entries = chunks.last().bounds_by_level; + for (auto const& mapping : mappings) { + auto entry = level_entries.find_if([&](auto const& existing_entry) { return existing_entry.leaf == mapping.leaf; }); + if (entry == level_entries.end()) { + level_entries.append({ mapping.leaf, {} }); + entry = level_entries.end() - 1; + } + if (mapping.unbounded) { + entry->unbounded = true; + } else if (mapping.to_leaf.has_value()) { + entry->bounds.unite(bounds_of_mapped_rect(*mapping.to_leaf, rect)); + } else { + entry->bounds.unite(rect); + } + } + }); + return chunks; +} + +static ChunkPlacement place_chunk_within(CommandChunk const& chunk, VisualContextIndex enclosing_context, SortingContexts const& contexts) +{ + if (chunk.context == enclosing_context) + return { NO_SORTING_CONTEXT, chunk.leaf }; + for (auto current = chunk.context; current != NO_SORTING_CONTEXT;) { + auto link = contexts.links.get(current.value()); + if (!link.has_value()) + break; + if (link->parent_context == enclosing_context) + return { current, link->parent_leaf }; + current = link->parent_context; + } + return { NO_SORTING_CONTEXT, chunk.leaf }; +} + +void DepthSortedPlanBuilder::emit_chunks(ReadonlySpan chunks, VisualContextIndex enclosing_context) +{ + size_t i = 0; + while (i < chunks.size()) { + auto child_context = place_chunk_within(chunks[i], enclosing_context, contexts).child_context; + if (child_context == NO_SORTING_CONTEXT) { + steps.append(DisplayListCommandRange { chunks[i].offset, chunks[i].size }); + ++i; + continue; + } + size_t run_end = i + 1; + while (run_end < chunks.size() && place_chunk_within(chunks[run_end], enclosing_context, contexts).child_context == child_context) + ++run_end; + sort_and_emit_context(chunks.slice(i, run_end - i), child_context); + i = run_end; + } +} + +void DepthSortedPlanBuilder::sort_and_emit_context(ReadonlySpan chunks, VisualContextIndex sorting_context) +{ + // The unit of sorting is a run of consecutive chunks sharing a plane, not the whole plane. Coplanar planes render + // in painting order, and separate runs of one plane interleaved with a coplanar sibling must keep their recorded + // positions relative to it. + struct PlaneRun { + VisualContextIndex leaf; + size_t begin { 0 }; + size_t end { 0 }; + Gfx::FloatRect bounds; + bool unbounded { false }; + }; + Vector runs; + for (size_t i = 0; i < chunks.size(); ++i) { + auto leaf = place_chunk_within(chunks[i], sorting_context, contexts).leaf; + if (runs.is_empty() || runs.last().leaf != leaf) + runs.append({ leaf, i, i + 1, {} }); + else + runs.last().end = i + 1; + for (auto const& entry : chunks[i].bounds_by_level) { + if (entry.leaf == leaf) { + runs.last().bounds.unite(entry.bounds); + runs.last().unbounded |= entry.unbounded; + } + } + } + + if (any_of(runs, [](auto const& run) { return run.unbounded; })) { + emit_chunks(chunks, sorting_context); + return; + } + + // Runs that are fully backface-culled, that project entirely behind the eye, or whose content bounds are empty + // draw nothing and are dropped rather than sorted. The bounds are inflated so a split piece's clip stays clear of + // the anti-aliased fringe of the content's own edges. + Vector polygons; + for (size_t run_index = 0; run_index < runs.size(); ++run_index) { + auto const& run = runs[run_index]; + if (run.bounds.is_empty()) + continue; + auto vertices = Gfx::map_rect_through_projection(transform_palette[run.leaf.value()], run.bounds.inflated(4, 4)); + if (vertices.size() < 3) + continue; + polygons.append({ move(vertices), run_index, false }); + } + + // FIXME: Pieces of a split plane whose content carries filter effects render incorrectly: each piece filters its + // clipped content independently, truncating filter output at the piece boundary and seaming it along the cut. + for (auto& polygon : Gfx::split_and_sort_polygons_back_to_front(move(polygons))) { + auto const& run = runs[polygon.plane_index]; + if (polygon.clipped) + steps.append(PushPlaneClip { move(polygon.vertices) }); + emit_chunks(chunks.slice(run.begin, run.end - run.begin), sorting_context); + if (polygon.clipped) + steps.append(PopPlaneClip {}); + } +} + +// https://drafts.csswg.org/css-transforms-2/#3d-rendering-contexts +// The element establishing the 3D rendering context, and each other 3D transformed element participating in the +// 3D rendering context, is rendered into its own plane. Intersection is performed between this set of planes, +// according to Newell's algorithm, with the planes transformed by the accumulated 3D transformation matrix. +// Coplanar 3D transformed elements are rendered in painting order. +// +// The command stream is partitioned into contiguous chunks that share a plane. Each 3D rendering context's +// planes are ordered back to front with a BSP tree over their content bounds; a plane cut by another's plane is +// replayed once per piece under a device-space polygon clip. Chunks in a nested context sort among themselves +// inside the plane of the outer context they render into. +Vector build_depth_sorted_replay_plan( + ReadonlyBytes commands, + AccumulatedVisualContextTree const& visual_context_tree, + ReadonlySpan transform_palette, + ReadonlySpan nearest_spatial_node, + ReadonlySpan backface_culled) +{ + auto contexts = visual_context_tree.resolve_sorting_contexts(); + auto chunks = partition_commands_into_plane_chunks(commands, contexts, transform_palette, nearest_spatial_node, backface_culled); + DepthSortedPlanBuilder builder { contexts, transform_palette, {} }; + builder.emit_chunks(chunks, NO_SORTING_CONTEXT); + return move(builder.steps); +} + +} diff --git a/Libraries/LibWeb/Painting/DepthSortedReplayPlan.h b/Libraries/LibWeb/Painting/DepthSortedReplayPlan.h new file mode 100644 index 0000000000000..658ade013316c --- /dev/null +++ b/Libraries/LibWeb/Painting/DepthSortedReplayPlan.h @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Tim Ledbetter + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Web::Painting { + +struct PushPlaneClip { + Vector vertices; +}; +struct PopPlaneClip { }; +using DepthSortedReplayStep = Variant; + +Vector build_depth_sorted_replay_plan(ReadonlyBytes commands, AccumulatedVisualContextTree const&, ReadonlySpan transform_palette, ReadonlySpan nearest_spatial_node, ReadonlySpan backface_culled); + +} diff --git a/Libraries/LibWeb/Painting/DisplayList.cpp b/Libraries/LibWeb/Painting/DisplayList.cpp index 55503a4a1fc43..92c6c2f2587e5 100644 --- a/Libraries/LibWeb/Painting/DisplayList.cpp +++ b/Libraries/LibWeb/Painting/DisplayList.cpp @@ -8,8 +8,10 @@ #include #include #include +#include #include #include +#include #include namespace Web::Painting { @@ -198,6 +200,7 @@ void DisplayListPlayer::execute_impl( backface_culled.clear_with_capacity(); backface_culled.ensure_capacity(nodes.size()); auto const replay_base_matrix = canvas_matrix(); + bool tree_has_sorting_contexts = false; for (size_t i = 0; i < nodes.size(); ++i) { auto const& node = nodes[i]; auto append_spatial = [&](Gfx::FloatMatrix4x4 const& local_matrix, bool flattens_inherited_transform = false) { @@ -220,6 +223,7 @@ void DisplayListPlayer::execute_impl( }; node.data.visit( [&](TransformData const& transform) { + tree_has_sorting_contexts |= transform.sorting_context_root_index.has_value(); append_spatial(transform.matrix_including_origin(), transform.flattens_inherited_transform); }, [&](PerspectiveData const& perspective) { @@ -288,6 +292,7 @@ void DisplayListPlayer::execute_impl( size_t applied_mask_frame_count = 0; auto restore_to_length = [&](size_t length) { + applied_context_index = {}; while (applied_frames.size() > length) { auto frame_node_index = applied_frames.take_last(); auto const* mask = applied_mask_frame_count > 0 @@ -358,7 +363,6 @@ void DisplayListPlayer::execute_impl( // The canvas is unwound to the shared prefix; clearing the applied index // keeps the fast path from reusing the pre-cull context while the frame // vector still enables prefix reuse on the next switch. - applied_context_index = {}; return SwitchResult::CulledByEffect; } } @@ -387,7 +391,7 @@ void DisplayListPlayer::execute_impl( } }, [&](ClipPathData const& clip_path) { - add_clip_path(clip_path.path, clip_path.fill_rule); + add_clip_path(clip_path.path, clip_path.fill_rule, true); }, [&](MaskData const& mask) { play_command(AddClipRect { .rect = mask.rect.to_type().to_type() }); @@ -404,7 +408,7 @@ void DisplayListPlayer::execute_impl( return SwitchResult::Switched; }; - DisplayList::for_each_command_header(commands, [&](DisplayListCommandHeader const& header, ReadonlyBytes payload) { + auto execute_command = [&](DisplayListCommandHeader const& header, ReadonlyBytes payload) { if (display_list_command_is_compositor_metadata(header.type)) return; @@ -455,7 +459,35 @@ void DisplayListPlayer::execute_impl( ENUMERATE_DISPLAY_LIST_COMMANDS(DISPATCH_DISPLAY_LIST_COMMAND) #undef DISPATCH_DISPLAY_LIST_COMMAND } - }); + }; + + if (!tree_has_sorting_contexts) { + DisplayList::for_each_command_header(commands, execute_command); + } else { + for (auto const& step : build_depth_sorted_replay_plan(commands, visual_context_tree, transform_palette, nearest_spatial_node, backface_culled)) { + step.visit( + [&](DisplayListCommandRange const& range) { + DisplayList::for_each_command_header(commands.slice(range.offset, range.size), execute_command); + }, + [&](PushPlaneClip const& clip) { + restore_to_length(0); + play_command(Save {}); + set_matrix(Gfx::FloatMatrix4x4::identity()); + current_ctm_space = {}; + Gfx::Path path; + path.move_to({ clip.vertices[0].x(), clip.vertices[0].y() }); + for (size_t i = 1; i < clip.vertices.size(); ++i) + path.line_to({ clip.vertices[i].x(), clip.vertices[i].y() }); + path.close(); + add_clip_path(path, Gfx::WindingRule::Nonzero, false); + }, + [&](PopPlaneClip const&) { + restore_to_length(0); + play_command(Restore {}); + current_ctm_space = {}; + }); + } + } restore_to_length(0); // Node spaces were entered by setting the canvas matrix absolutely, outside any save, so the diff --git a/Libraries/LibWeb/Painting/DisplayList.h b/Libraries/LibWeb/Painting/DisplayList.h index 38ee3169e835d..88e8da0486f0e 100644 --- a/Libraries/LibWeb/Painting/DisplayList.h +++ b/Libraries/LibWeb/Painting/DisplayList.h @@ -71,7 +71,7 @@ class WEB_API DisplayListPlayer { virtual Gfx::FloatMatrix4x4 canvas_matrix() const = 0; virtual bool would_be_fully_clipped_by_painter(Gfx::IntRect) const = 0; - virtual void add_clip_path(Gfx::Path const&, Gfx::WindingRule) = 0; + virtual void add_clip_path(Gfx::Path const&, Gfx::WindingRule, bool anti_aliased) = 0; DisplayList const* m_active_display_list { nullptr }; AccumulatedVisualContextTree const* m_active_visual_context_tree { nullptr }; diff --git a/Libraries/LibWeb/Painting/DisplayListCommand.h b/Libraries/LibWeb/Painting/DisplayListCommand.h index 8699b7cf69dfd..1763fd52977ab 100644 --- a/Libraries/LibWeb/Painting/DisplayListCommand.h +++ b/Libraries/LibWeb/Painting/DisplayListCommand.h @@ -589,7 +589,6 @@ struct PaintNestedDisplayList { Gfx::IntSize list_size; [[nodiscard]] Gfx::IntRect bounding_rect() const { return Gfx::enclosing_int_rect(rect); } - void dump(StringBuilder&) const; }; @@ -703,11 +702,13 @@ struct PaintScrollBar { VisualContextIndex scroll_node_index; Gfx::IntRect gutter_rect; Gfx::IntRect thumb_rect; + Gfx::IntRect track_rect; double scroll_size; Color thumb_color; Color track_color; bool vertical; + [[nodiscard]] Gfx::IntRect bounding_rect() const { return track_rect.united(thumb_rect); } void dump(StringBuilder&) const; }; diff --git a/Libraries/LibWeb/Painting/DisplayListDamage.cpp b/Libraries/LibWeb/Painting/DisplayListDamage.cpp index 3f1ddc5bba13b..24b6fc30e3b53 100644 --- a/Libraries/LibWeb/Painting/DisplayListDamage.cpp +++ b/Libraries/LibWeb/Painting/DisplayListDamage.cpp @@ -126,7 +126,9 @@ static bool visual_context_data_is_equal(VisualContextIndex a_index, VisualConte auto const* other = b.get_pointer(); return other && matrices_are_equal(data.matrix, other->matrix) && data.origin == other->origin && data.flattens_inherited_transform == other->flattens_inherited_transform - && data.role == other->role; + && data.sorting_context_root_index == other->sorting_context_root_index + && data.role == other->role + && data.synthetic_plane == other->synthetic_plane; }, [&](PerspectiveData const& data) { auto const* other = b.get_pointer(); @@ -277,7 +279,7 @@ Optional compute_display_list_damage( if (visual_context_chains_are_equal(old_command.header.context_index, old_visual_context_tree, old_scroll_state, new_command.header.context_index, new_visual_context_tree, new_scroll_state)) return; if (!old_command.header.has_bounding_rect || !new_command.header.has_bounding_rect) { - if (old_command.header.type == DisplayListCommandType::CompositorViewportScrollbar || old_command.header.type == DisplayListCommandType::PaintScrollBar) + if (old_command.header.type == DisplayListCommandType::CompositorViewportScrollbar) changed_unbounded_command = true; return; } diff --git a/Libraries/LibWeb/Painting/DisplayListPlayerSkia.cpp b/Libraries/LibWeb/Painting/DisplayListPlayerSkia.cpp index 5d7e5bab49dc9..f6c51f1f19b24 100644 --- a/Libraries/LibWeb/Painting/DisplayListPlayerSkia.cpp +++ b/Libraries/LibWeb/Painting/DisplayListPlayerSkia.cpp @@ -1001,7 +1001,7 @@ void DisplayListPlayerSkia::play_command(PaintConicGradient const& command) void DisplayListPlayerSkia::play_command(AddClipPath const& command) { - add_clip_path(path_from_data(command.path_data), command.winding_rule); + add_clip_path(path_from_data(command.path_data), command.winding_rule, true); } void DisplayListPlayerSkia::play_command(AddRoundedRectClip const& command) @@ -1202,12 +1202,12 @@ Gfx::FloatMatrix4x4 DisplayListPlayerSkia::canvas_matrix() const return to_gfx_matrix4x4(surface().canvas().getLocalToDevice()); } -void DisplayListPlayerSkia::add_clip_path(Gfx::Path const& path, Gfx::WindingRule winding_rule) +void DisplayListPlayerSkia::add_clip_path(Gfx::Path const& path, Gfx::WindingRule winding_rule, bool anti_aliased) { auto& canvas = surface().canvas(); auto sk_path = to_skia_path(path); sk_path.setFillType(to_skia_path_fill_type(winding_rule)); - canvas.clipPath(sk_path, true); + canvas.clipPath(sk_path, anti_aliased); } bool DisplayListPlayerSkia::would_be_fully_clipped_by_painter(Gfx::IntRect rect) const diff --git a/Libraries/LibWeb/Painting/DisplayListPlayerSkia.h b/Libraries/LibWeb/Painting/DisplayListPlayerSkia.h index b3d5018a2969a..b0434f2f391f6 100644 --- a/Libraries/LibWeb/Painting/DisplayListPlayerSkia.h +++ b/Libraries/LibWeb/Painting/DisplayListPlayerSkia.h @@ -50,7 +50,7 @@ class WEB_API DisplayListPlayerSkia final : public DisplayListPlayer { void set_matrix(Gfx::FloatMatrix4x4 const&) override; Gfx::FloatMatrix4x4 canvas_matrix() const override; - void add_clip_path(Gfx::Path const&, Gfx::WindingRule) override; + void add_clip_path(Gfx::Path const&, Gfx::WindingRule, bool anti_aliased) override; bool would_be_fully_clipped_by_painter(Gfx::IntRect) const override; diff --git a/Libraries/LibWeb/Painting/DisplayListRecorder.cpp b/Libraries/LibWeb/Painting/DisplayListRecorder.cpp index aef9b56390b02..5e3bece66e368 100644 --- a/Libraries/LibWeb/Painting/DisplayListRecorder.cpp +++ b/Libraries/LibWeb/Painting/DisplayListRecorder.cpp @@ -733,12 +733,13 @@ void DisplayListRecorder::fill_rect_with_rounded_corners(Gfx::IntRect const& a_r { bottom_left_radius, bottom_left_radius } }); } -void DisplayListRecorder::paint_scrollbar(VisualContextIndex scroll_node_index, Gfx::IntRect gutter_rect, Gfx::IntRect thumb_rect, double scroll_size, Color thumb_color, Color track_color, bool vertical) +void DisplayListRecorder::paint_scrollbar(VisualContextIndex scroll_node_index, Gfx::IntRect gutter_rect, Gfx::IntRect thumb_rect, Gfx::IntRect track_rect, double scroll_size, Color thumb_color, Color track_color, bool vertical) { append_command(PaintScrollBar { .scroll_node_index = scroll_node_index, .gutter_rect = gutter_rect, .thumb_rect = thumb_rect, + .track_rect = track_rect, .scroll_size = scroll_size, .thumb_color = thumb_color, .track_color = track_color, diff --git a/Libraries/LibWeb/Painting/DisplayListRecorder.h b/Libraries/LibWeb/Painting/DisplayListRecorder.h index ad803962f1a89..516c2be0c15e4 100644 --- a/Libraries/LibWeb/Painting/DisplayListRecorder.h +++ b/Libraries/LibWeb/Painting/DisplayListRecorder.h @@ -139,7 +139,7 @@ class WEB_API DisplayListRecorder { void fill_rect_with_rounded_corners(Gfx::IntRect const& a_rect, Color color, int radius); void fill_rect_with_rounded_corners(Gfx::IntRect const& a_rect, Color color, int top_left_radius, int top_right_radius, int bottom_right_radius, int bottom_left_radius); - void paint_scrollbar(VisualContextIndex scroll_node_index, Gfx::IntRect gutter_rect, Gfx::IntRect thumb_rect, double scroll_size, Color thumb_color, Color track_color, bool vertical); + void paint_scrollbar(VisualContextIndex scroll_node_index, Gfx::IntRect gutter_rect, Gfx::IntRect thumb_rect, Gfx::IntRect track_rect, double scroll_size, Color thumb_color, Color track_color, bool vertical); void compositor_scroll_node(CompositorScrollNode const&); void compositor_sticky_area(CompositorStickyArea const&); diff --git a/Libraries/LibWeb/Painting/HitTestDisplayList.cpp b/Libraries/LibWeb/Painting/HitTestDisplayList.cpp index b71363b6866e6..77f2281db68a7 100644 --- a/Libraries/LibWeb/Painting/HitTestDisplayList.cpp +++ b/Libraries/LibWeb/Painting/HitTestDisplayList.cpp @@ -1146,6 +1146,96 @@ void HitTestDisplayList::find_items_in_list(Vector const& item_indices, } } +namespace { + +class PlaneDepthOrder { +public: + PlaneDepthOrder(AccumulatedVisualContextTree const& visual_context_tree, SortingContexts const& sorting_contexts, Gfx::FloatPoint device_point, ScrollStateSnapshot const& scroll_state) + : m_visual_context_tree(visual_context_tree) + , m_sorting_contexts(sorting_contexts) + , m_device_point(device_point) + , m_scroll_state(scroll_state) + { + } + + bool is_in_front_of(size_t item_index, VisualContextIndex context_index, size_t other_item_index, VisualContextIndex other_context_index) + { + auto depth = depth_key(context_index); + auto other_depth = depth_key(other_context_index); + if (depth == other_depth) + return item_index > other_item_index; + if (!depth.has_value() || !other_depth.has_value()) + return depth.has_value(); + return *depth > *other_depth; + } + +private: + Optional depth_key(VisualContextIndex context_index) + { + auto leaf = m_sorting_contexts.leaf_by_node[context_index.value()]; + return m_depth_key_by_plane.ensure(leaf.value(), [&]() -> Optional { + auto depth = m_visual_context_tree.plane_depth_at_point_for_hit_test(leaf, m_device_point, m_scroll_state); + if (!depth.has_value()) + return {}; + static constexpr float depth_limit = 16777216.0f; + return llround(clamp(*depth, -depth_limit, depth_limit) * 8.0f); + }); + } + + AccumulatedVisualContextTree const& m_visual_context_tree; + SortingContexts const& m_sorting_contexts; + Gfx::FloatPoint m_device_point; + ScrollStateSnapshot const& m_scroll_state; + HashMap> m_depth_key_by_plane; +}; + +} + +SortingContexts const& HitTestDisplayList::ensure_sorting_contexts(ViewportPaintable const& viewport_paintable) const +{ + // The version check at every entry point guarantees the tree still matches this list. + if (!m_sorting_contexts.has_value()) + m_sorting_contexts = viewport_paintable.visual_context_tree().resolve_sorting_contexts(); + return *m_sorting_contexts; +} + +size_t HitTestDisplayList::topmost_item_by_plane_depth(size_t topmost_item_index, CSSPixelPoint point, ViewportPaintable const& viewport_paintable, double device_pixels_per_css_pixel, ChromeMetrics const& chrome_metrics) const +{ + auto const& sorting_contexts = *m_sorting_contexts; + auto device_point = point.to_type() * static_cast(device_pixels_per_css_pixel); + PlaneDepthOrder depth_order { viewport_paintable.visual_context_tree(), sorting_contexts, device_point, viewport_paintable.scroll_state_snapshot() }; + + auto group_context = sorting_contexts.outermost_context_of(sorting_contexts.context_by_node[m_items[topmost_item_index].visual_context_index.value()]); + + auto winning_item_index = topmost_item_index; + Vector hit_item_indices; + for (auto visual_context_index : m_used_visual_context_indices) { + if (sorting_contexts.leaf_by_node[visual_context_index.value()] == NO_SORTING_CONTEXT) + continue; + if (sorting_contexts.outermost_context_of(sorting_contexts.context_by_node[visual_context_index.value()]) != group_context) + continue; + + auto local_float_point = local_point_for_visual_context(visual_context_index, point, viewport_paintable, device_pixels_per_css_pixel); + if (!local_float_point.has_value()) + continue; + auto local_point = local_float_point->to_type(); + + auto const& spatial_index = m_spatial_indexes[visual_context_index.value()]; + hit_item_indices.clear_with_capacity(); + find_items_in_list(spatial_index->unbucketed_items, *local_float_point, chrome_metrics, hit_item_indices); + auto x = spatial_index_cell_for(local_point.x()); + auto y = spatial_index_cell_for(local_point.y()); + if (auto bucket = spatial_index->cells.get(spatial_index_cell_key(x, y)); bucket.has_value()) + find_items_in_list(*bucket, *local_float_point, chrome_metrics, hit_item_indices); + + for (auto item_index : hit_item_indices) { + if (item_index != winning_item_index && depth_order.is_in_front_of(item_index, visual_context_index, winning_item_index, m_items[winning_item_index].visual_context_index)) + winning_item_index = item_index; + } + } + return winning_item_index; +} + Optional HitTestDisplayList::caret_position_from_point(CSSPixelPoint point, ViewportPaintable const& viewport_paintable, double device_pixels_per_css_pixel, ChromeMetrics const& chrome_metrics, CaretPositionMode mode, GC::Ptr constraint_scope) const { if (m_visual_context_tree_version != viewport_paintable.visual_context_tree().version()) @@ -1155,6 +1245,8 @@ Optional HitTestDisplayList::caret_position_from_point(CSSPixelPo // First find both the topmost hit-test item and the topmost item that can directly produce a caret. // Non-caret items are still needed to keep later line fallback scoped to the hit content. + // FIXME: Caret placement compares items by record order alone, ignoring the depth-sorted paint order of + // planes inside 3D rendering contexts. Optional topmost_item_index; Optional topmost_item_local_point; Optional topmost_hit_item_index; @@ -1434,6 +1526,18 @@ Optional HitTestDisplayList::hit_test(CSSPixelPoint point, HitTes if (!topmost_item_index.has_value()) return {}; + // Record order misranks content inside a 3D rendering context, whose planes paint depth sorted. A winner + // on such a plane is re-resolved against every hit plane of its outermost context. Content outside the + // context keeps record order, which stays correct because a context's items are recorded contiguously. + auto const& sorting_contexts = ensure_sorting_contexts(viewport_paintable); + if (!sorting_contexts.is_empty() && sorting_contexts.leaf_by_node[m_items[*topmost_item_index].visual_context_index.value()] != NO_SORTING_CONTEXT) { + auto depth_winner = topmost_item_by_plane_depth(*topmost_item_index, point, viewport_paintable, device_pixels_per_css_pixel, chrome_metrics); + if (depth_winner != *topmost_item_index) { + topmost_item_index = depth_winner; + topmost_item_local_point = {}; + } + } + auto const& item = m_items[*topmost_item_index]; if (!topmost_item_local_point.has_value()) { topmost_item_local_point = local_css_pixel_point_for_visual_context(item.visual_context_index, point, viewport_paintable, device_pixels_per_css_pixel); @@ -1472,6 +1576,32 @@ TraversalDecision HitTestDisplayList::hit_test_all(CSSPixelPoint point, Viewport quick_sort(hit_item_indices, [](auto a, auto b) { return a > b; }); + // Runs of hits on the planes of one 3D rendering context are reordered front to back. A context's items + // are recorded contiguously, so after the record-order sort its hits sit adjacent. + auto const& sorting_contexts = ensure_sorting_contexts(viewport_paintable); + if (!sorting_contexts.is_empty()) { + PlaneDepthOrder depth_order { viewport_paintable.visual_context_tree(), sorting_contexts, point.to_type() * static_cast(device_pixels_per_css_pixel), viewport_paintable.scroll_state_snapshot() }; + auto group_of = [&](size_t item_index) { + auto context_index = m_items[item_index].visual_context_index; + if (sorting_contexts.leaf_by_node[context_index.value()] == NO_SORTING_CONTEXT) + return NO_SORTING_CONTEXT; + return sorting_contexts.outermost_context_of(sorting_contexts.context_by_node[context_index.value()]); + }; + for (size_t run_begin = 0; run_begin < hit_item_indices.size();) { + auto group = group_of(hit_item_indices[run_begin]); + size_t run_end = run_begin + 1; + while (group != NO_SORTING_CONTEXT && run_end < hit_item_indices.size() && group_of(hit_item_indices[run_end]) == group) + ++run_end; + if (group != NO_SORTING_CONTEXT && run_end - run_begin > 1) { + auto run = hit_item_indices.span().slice(run_begin, run_end - run_begin); + quick_sort(run, [&](auto a, auto b) { + return depth_order.is_in_front_of(a, m_items[a].visual_context_index, b, m_items[b].visual_context_index); + }); + } + run_begin = run_end; + } + } + Optional previous_item_index; for (auto item_index : hit_item_indices) { if (previous_item_index == item_index) diff --git a/Libraries/LibWeb/Painting/HitTestDisplayList.h b/Libraries/LibWeb/Painting/HitTestDisplayList.h index 2d54dd6537df3..122ed2da7bfc2 100644 --- a/Libraries/LibWeb/Painting/HitTestDisplayList.h +++ b/Libraries/LibWeb/Painting/HitTestDisplayList.h @@ -166,6 +166,8 @@ class WEB_API HitTestDisplayList : public RefCounted { [[nodiscard]] Optional caret_line_index_for_position(DOM::Node const&, size_t offset, TextAffinity) const; [[nodiscard]] bool line_contains_descendant_of(CaretLine const&, DOM::Node const&) const; [[nodiscard]] bool item_is_inline_adjacent_to_line(Item const&, CaretLine const&) const; + SortingContexts const& ensure_sorting_contexts(ViewportPaintable const&) const; + [[nodiscard]] size_t topmost_item_by_plane_depth(size_t topmost_item_index, CSSPixelPoint, ViewportPaintable const&, double device_pixels_per_css_pixel, ChromeMetrics const&) const; void find_topmost_item_in_list(Vector const&, Gfx::FloatPoint local_float_point, ChromeMetrics const&, Optional& topmost_item_index) const; void find_topmost_caret_item_in_list(Vector const&, Gfx::FloatPoint local_float_point, ChromeMetrics const&, Optional& topmost_item_index) const; void find_items_in_list(Vector const&, Gfx::FloatPoint local_float_point, ChromeMetrics const&, Vector& hit_item_indices) const; @@ -181,6 +183,7 @@ class WEB_API HitTestDisplayList : public RefCounted { mutable Vector m_caret_lines; mutable Vector> m_spatial_indexes; mutable Vector m_used_visual_context_indices; + mutable Optional m_sorting_contexts; }; } diff --git a/Libraries/LibWeb/Painting/Paintable.cpp b/Libraries/LibWeb/Painting/Paintable.cpp index 48a9451a440f3..3a835232a9de8 100644 --- a/Libraries/LibWeb/Painting/Paintable.cpp +++ b/Libraries/LibWeb/Painting/Paintable.cpp @@ -1698,7 +1698,7 @@ Optional Paintable::compute_scrollbar_data(ScrollDirec CSSPixels min_thumb_length = min(usable_scrollbar_length, metrics.scroll_thumb_min_length); CSSPixels thumb_length = max(usable_scrollbar_length * (scrollport_size / scrollable_overflow_length), min_thumb_length); - ScrollbarData scrollbar_data = { .gutter_rect = {}, .thumb_rect = scrollbar_rect.value(), .thumb_travel_to_scroll_ratio = 0 }; + ScrollbarData scrollbar_data = { .gutter_rect = {}, .thumb_rect = scrollbar_rect.value(), .track_rect = scrollbar_rect.value(), .thumb_travel_to_scroll_ratio = 0 }; if (scrollable_overflow_length > scrollport_size) scrollbar_data.thumb_travel_to_scroll_ratio = (usable_scrollbar_length - thumb_length) / (scrollable_overflow_length - scrollport_size); @@ -1851,6 +1851,7 @@ void Paintable::paint(DisplayListRecordingContext& context, PaintPhase phase) co m_own_scroll_node_index, gutter_rect, context.rounded_device_rect(scrollbar_data->thumb_rect).to_type(), + context.rounded_device_rect(scrollbar_data->track_rect).to_type(), scrollbar_data->thumb_travel_to_scroll_ratio.to_double(), scrollbar_colors.thumb_color, scrollbar_colors.track_color, diff --git a/Libraries/LibWeb/Painting/Paintable.h b/Libraries/LibWeb/Painting/Paintable.h index b580ac94b8de3..9dae6c2fad7fb 100644 --- a/Libraries/LibWeb/Painting/Paintable.h +++ b/Libraries/LibWeb/Painting/Paintable.h @@ -313,6 +313,7 @@ class WEB_API Paintable struct ScrollbarData { CSSPixelRect gutter_rect; CSSPixelRect thumb_rect; + CSSPixelRect track_rect; CSSPixelFraction thumb_travel_to_scroll_ratio { 0 }; }; enum class ScrollDirection { diff --git a/Services/Compositor/ViewportScrollbarController.cpp b/Services/Compositor/ViewportScrollbarController.cpp index 80454734ebd85..c5551169fa920 100644 --- a/Services/Compositor/ViewportScrollbarController.cpp +++ b/Services/Compositor/ViewportScrollbarController.cpp @@ -226,6 +226,7 @@ bool ViewportScrollbarController::paint(Gfx::PaintingSurface& surface, Web::Pain .scroll_node_index = scrollbar.scroll_node_index, .gutter_rect = scrollbar_gutter_rect(scrollbar, expanded), .thumb_rect = translated_thumb_rect(scrollbar, scroll_state_snapshot, expanded), + .track_rect = scrollbar_gutter_rect(scrollbar, true), .scroll_size = scrollbar_scroll_size(scrollbar, expanded), .thumb_color = scrollbar.thumb_color, .track_color = scrollbar.track_color, diff --git a/Tests/LibGfx/CMakeLists.txt b/Tests/LibGfx/CMakeLists.txt index 7e6d938b52762..09f829f989892 100644 --- a/Tests/LibGfx/CMakeLists.txt +++ b/Tests/LibGfx/CMakeLists.txt @@ -1,5 +1,6 @@ set(TEST_SOURCES BenchmarkJPEGLoader.cpp + TestBSPTree.cpp TestBitmapExport.cpp TestColor.cpp TestFont.cpp diff --git a/Tests/LibGfx/TestBSPTree.cpp b/Tests/LibGfx/TestBSPTree.cpp new file mode 100644 index 0000000000000..d659998c90723 --- /dev/null +++ b/Tests/LibGfx/TestBSPTree.cpp @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, Tim Ledbetter + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include + +static Gfx::BSPPolygon make_z_plane_polygon(float z, size_t plane_index) +{ + return { { { -10, -10, z }, { 10, -10, z }, { 10, 10, z }, { -10, 10, z } }, plane_index, false }; +} + +static Gfx::FloatVector3 centroid_of(Gfx::BSPPolygon const& polygon) +{ + Gfx::FloatVector3 sum { 0, 0, 0 }; + for (auto const& vertex : polygon.vertices) + sum += vertex; + return sum / static_cast(polygon.vertices.size()); +} + +TEST_CASE(map_rect_through_projection_maps_corners_in_order) +{ + auto vertices = Gfx::map_rect_through_projection(Gfx::FloatMatrix4x4::identity(), { 10, 20, 30, 40 }); + EXPECT_EQ(vertices.size(), 4u); + EXPECT_EQ(vertices[0], Gfx::FloatVector3(10, 20, 0)); + EXPECT_EQ(vertices[1], Gfx::FloatVector3(40, 20, 0)); + EXPECT_EQ(vertices[2], Gfx::FloatVector3(40, 60, 0)); + EXPECT_EQ(vertices[3], Gfx::FloatVector3(10, 60, 0)); +} + +TEST_CASE(map_rect_through_projection_performs_the_perspective_divide) +{ + auto matrix = Gfx::FloatMatrix4x4::identity(); + matrix[3, 0] = 0.015625f; + auto vertices = Gfx::map_rect_through_projection(matrix, { 0, 0, 64, 32 }); + EXPECT_EQ(vertices.size(), 4u); + EXPECT_EQ(vertices[0], Gfx::FloatVector3(0, 0, 0)); + EXPECT_EQ(vertices[1], Gfx::FloatVector3(32, 0, 0)); + EXPECT_EQ(vertices[2], Gfx::FloatVector3(32, 16, 0)); + EXPECT_EQ(vertices[3], Gfx::FloatVector3(0, 32, 0)); +} + +TEST_CASE(map_rect_through_projection_clips_the_region_behind_the_eye) +{ + auto matrix = Gfx::FloatMatrix4x4::identity(); + matrix[3, 0] = -0.03125f; + auto vertices = Gfx::map_rect_through_projection(matrix, { 0, 0, 64, 32 }); + EXPECT_EQ(vertices.size(), 4u); + EXPECT_EQ(vertices[0], Gfx::FloatVector3(0, 0, 0)); + EXPECT(vertices[1].x() > 100'000); + EXPECT(vertices[2].x() > 100'000); + EXPECT(vertices[2].y() > 100'000); + EXPECT_EQ(vertices[3], Gfx::FloatVector3(0, 32, 0)); +} + +TEST_CASE(map_rect_through_projection_drops_a_rect_entirely_behind_the_eye) +{ + auto matrix = Gfx::FloatMatrix4x4::identity(); + matrix[3, 3] = -1; + EXPECT(Gfx::map_rect_through_projection(matrix, { 0, 0, 64, 32 }).is_empty()); +} + +TEST_CASE(parallel_planes_sort_back_to_front_for_any_paint_order) +{ + Vector polygons; + for (size_t i = 0; i < 20; ++i) + polygons.append(make_z_plane_polygon(static_cast((i * 7) % 20), i)); + + // Painting proceeds in ascending z, where the largest z is nearest the viewer and paints last. + auto sorted = Gfx::split_and_sort_polygons_back_to_front(move(polygons)); + EXPECT_EQ(sorted.size(), 20u); + for (size_t i = 0; i < sorted.size(); ++i) { + EXPECT(!sorted[i].clipped); + EXPECT_EQ(sorted[i].vertices.first().z(), static_cast(i)); + } +} + +TEST_CASE(reversed_winding_does_not_affect_depth_order) +{ + Vector polygons; + polygons.append({ { { -10, 10, 5 }, { 10, 10, 5 }, { 10, -10, 5 }, { -10, -10, 5 } }, 0, false }); + polygons.append(make_z_plane_polygon(-5, 1)); + + auto sorted = Gfx::split_and_sort_polygons_back_to_front(move(polygons)); + EXPECT_EQ(sorted.size(), 2u); + EXPECT_EQ(sorted[0].plane_index, 1u); + EXPECT_EQ(sorted[1].plane_index, 0u); +} + +TEST_CASE(coplanar_polygons_keep_paint_order) +{ + Vector polygons; + for (size_t i = 0; i < 3; ++i) + polygons.append(make_z_plane_polygon(0, i)); + + auto sorted = Gfx::split_and_sort_polygons_back_to_front(move(polygons)); + EXPECT_EQ(sorted.size(), 3u); + for (size_t i = 0; i < sorted.size(); ++i) + EXPECT_EQ(sorted[i].plane_index, i); +} + +TEST_CASE(coplanar_polygons_keep_paint_order_when_sorted_against_other_planes) +{ + // The third plane is tilted so the planes do not count as parallel and ordering runs through the tree. + Vector polygons; + polygons.append(make_z_plane_polygon(0, 0)); + polygons.append(make_z_plane_polygon(0, 1)); + polygons.append({ { { -10, -10, -50.1f }, { 10, -10, -49.9f }, { 10, 10, -49.9f }, { -10, 10, -50.1f } }, 2, false }); + + auto sorted = Gfx::split_and_sort_polygons_back_to_front(move(polygons)); + EXPECT_EQ(sorted.size(), 3u); + EXPECT_EQ(sorted[0].plane_index, 2u); + EXPECT_EQ(sorted[1].plane_index, 0u); + EXPECT_EQ(sorted[2].plane_index, 1u); +} + +TEST_CASE(intersecting_planes_are_split_into_ordered_pieces) +{ + Vector polygons; + polygons.append(make_z_plane_polygon(0, 0)); + // A polygon on the plane z = x, crossing the first polygon along the line x = 0. + polygons.append({ { { -10, -10, -10 }, { 10, -10, 10 }, { 10, 10, 10 }, { -10, 10, -10 } }, 1, false }); + + auto sorted = Gfx::split_and_sort_polygons_back_to_front(move(polygons)); + EXPECT_EQ(sorted.size(), 3u); + + // The crossing polygon stays whole and the flat one is cut into a piece on either side of it. The + // piece with positive x lies behind the crossing plane and paints first. + EXPECT_EQ(sorted[0].plane_index, 0u); + EXPECT(sorted[0].clipped); + EXPECT(centroid_of(sorted[0]).x() > 0); + + EXPECT_EQ(sorted[1].plane_index, 1u); + EXPECT(!sorted[1].clipped); + + EXPECT_EQ(sorted[2].plane_index, 0u); + EXPECT(sorted[2].clipped); + EXPECT(centroid_of(sorted[2]).x() < 0); +} + +TEST_CASE(polygons_without_a_plane_are_omitted) +{ + Vector polygons; + polygons.append({ { { 0, 0, 0 }, { 1, 0, 0 }, { 2, 0, 0 } }, 0, false }); + polygons.append(make_z_plane_polygon(0, 1)); + + auto sorted = Gfx::split_and_sort_polygons_back_to_front(move(polygons)); + EXPECT_EQ(sorted.size(), 1u); + EXPECT_EQ(sorted[0].plane_index, 1u); +} + +TEST_CASE(small_distant_parallel_planes_sort_without_splitting) +{ + // Two 8x8 parallel quads far from the origin, two units apart along their shared normal. + Vector polygons; + polygons.append({ { { 101230.914f, 99866.41f, 3141.205f }, { 101236.88f, 99868.2f, 3146.2156f }, { 101235.51f, 99876.0f, 3145.0588f }, { 101229.54f, 99874.195f, 3140.0483f } }, 0, false }); + polygons.append({ { { 101232.2f, 99866.41f, 3139.673f }, { 101238.17f, 99868.2f, 3144.6836f }, { 101236.8f, 99876.0f, 3143.5269f }, { 101230.83f, 99874.195f, 3138.5164f } }, 1, false }); + + auto sorted = Gfx::split_and_sort_polygons_back_to_front(move(polygons)); + EXPECT_EQ(sorted.size(), 2u); + EXPECT_EQ(sorted[0].plane_index, 1u); + EXPECT(!sorted[0].clipped); + EXPECT_EQ(sorted[1].plane_index, 0u); + EXPECT(!sorted[1].clipped); +} diff --git a/Tests/LibWeb/Ref/expected/scrollbar-on-3d-rendering-context-plane-ref.html b/Tests/LibWeb/Ref/expected/scrollbar-on-3d-rendering-context-plane-ref.html new file mode 100644 index 0000000000000..e9db9dcf39f97 --- /dev/null +++ b/Tests/LibWeb/Ref/expected/scrollbar-on-3d-rendering-context-plane-ref.html @@ -0,0 +1,11 @@ + + +
+
+
diff --git a/Tests/LibWeb/Ref/expected/singular-transform-in-3d-rendering-context-ref.html b/Tests/LibWeb/Ref/expected/singular-transform-in-3d-rendering-context-ref.html new file mode 100644 index 0000000000000..200978562d0fe --- /dev/null +++ b/Tests/LibWeb/Ref/expected/singular-transform-in-3d-rendering-context-ref.html @@ -0,0 +1,10 @@ + + +
diff --git a/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/preserve3d-and-flattening-002-ref.html b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/preserve3d-and-flattening-002-ref.html new file mode 100644 index 0000000000000..5a2b59cb9054c --- /dev/null +++ b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/preserve3d-and-flattening-002-ref.html @@ -0,0 +1,34 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + +
+
+
diff --git a/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/reference/css-transform-3d-transform-style-ref.html b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/reference/css-transform-3d-transform-style-ref.html new file mode 100644 index 0000000000000..a47b14756de57 --- /dev/null +++ b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/reference/css-transform-3d-transform-style-ref.html @@ -0,0 +1,37 @@ + + + + + CSS Transforms Test: rotateY with transform-style on nested elements + + + + +

Test passes if there is a green square and a blue square, and no any red.

+
+
+
+
+ + diff --git a/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/reference/ttwf-css-3d-polygon-cycle-ref.html b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/reference/ttwf-css-3d-polygon-cycle-ref.html new file mode 100644 index 0000000000000..e5c99eae722d3 --- /dev/null +++ b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/reference/ttwf-css-3d-polygon-cycle-ref.html @@ -0,0 +1,66 @@ + + + + + CSS Transforms Test: 3d transform polygon cycle + + + + + +

The test passes if there red is over green, green is over blue and blue is over red.

+
+
+
+
+
+
+
+
+ + diff --git a/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/scrollable-hidden-3d-transform-z-ref.html b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/scrollable-hidden-3d-transform-z-ref.html new file mode 100644 index 0000000000000..b2399db472f50 --- /dev/null +++ b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/scrollable-hidden-3d-transform-z-ref.html @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/scrollable-scroll-3d-transform-z-ref.html b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/scrollable-scroll-3d-transform-z-ref.html new file mode 100644 index 0000000000000..b2399db472f50 --- /dev/null +++ b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/scrollable-scroll-3d-transform-z-ref.html @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/transform3d-sorting-006-ref.html b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/transform3d-sorting-006-ref.html new file mode 100644 index 0000000000000..8ed7f552827da --- /dev/null +++ b/Tests/LibWeb/Ref/expected/wpt-import/css/css-transforms/transform3d-sorting-006-ref.html @@ -0,0 +1,30 @@ + + + + CSS Reftest Reference + + + + +
+
+
+
+ + diff --git a/Tests/LibWeb/Ref/input/scrollbar-on-3d-rendering-context-plane.html b/Tests/LibWeb/Ref/input/scrollbar-on-3d-rendering-context-plane.html new file mode 100644 index 0000000000000..8dfd3f032bc33 --- /dev/null +++ b/Tests/LibWeb/Ref/input/scrollbar-on-3d-rendering-context-plane.html @@ -0,0 +1,19 @@ + + + +
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/singular-transform-in-3d-rendering-context.html b/Tests/LibWeb/Ref/input/singular-transform-in-3d-rendering-context.html new file mode 100644 index 0000000000000..ba1536b75d5cc --- /dev/null +++ b/Tests/LibWeb/Ref/input/singular-transform-in-3d-rendering-context.html @@ -0,0 +1,30 @@ + + + +
+
+
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/css-transform-3d-transform-style.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/css-transform-3d-transform-style.html new file mode 100644 index 0000000000000..52a569c4f96cf --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/css-transform-3d-transform-style.html @@ -0,0 +1,50 @@ + + + + + CSS Transforms Test: rotateY with transform-style on nested elements + + + + + + + + +

Test passes if there is a green square and a blue square, and no any red.

+
+
+
+
+
+
+ + diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-002.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-002.html new file mode 100644 index 0000000000000..a6ca5a5d56c41 --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-002.html @@ -0,0 +1,46 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + +
+
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-003.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-003.html new file mode 100644 index 0000000000000..e1402636d720f --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-003.html @@ -0,0 +1,47 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + +
+
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-001.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-001.html new file mode 100644 index 0000000000000..de97c931ff5a2 --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-001.html @@ -0,0 +1,34 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + +

Pass if there is NO red below:

+ +
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-002.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-002.html new file mode 100644 index 0000000000000..5dc2b44a6ff18 --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-002.html @@ -0,0 +1,37 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + +

Pass if there is NO red below:

+ +
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-003.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-003.html new file mode 100644 index 0000000000000..588d75808b32d --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-003.html @@ -0,0 +1,36 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + +

Pass if there is NO red below:

+ +
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-004.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-004.html new file mode 100644 index 0000000000000..71841c5921051 --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-004.html @@ -0,0 +1,39 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + +

Pass if there is NO red below:

+ +
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-005.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-005.html new file mode 100644 index 0000000000000..5b6c642335607 --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-005.html @@ -0,0 +1,44 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + +

Pass if there is NO red below:

+ +
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-006.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-006.html new file mode 100644 index 0000000000000..346c5ad84dff0 --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-006.html @@ -0,0 +1,53 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + +
+
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-007.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-007.html new file mode 100644 index 0000000000000..d27ed2a3897fc --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-007.html @@ -0,0 +1,54 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + +
+
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-008.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-008.html new file mode 100644 index 0000000000000..58f70a204ee95 --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/preserve3d-and-flattening-z-order-008.html @@ -0,0 +1,56 @@ + + +CSS Test (Transforms): Flattening at the leafward edges of a preserve-3d scene + + + + + + + + + + +

Pass if there is NO red below:

+ +
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/scrollable-hidden-3d-transform-z.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/scrollable-hidden-3d-transform-z.html new file mode 100644 index 0000000000000..b9609d3bcfd1d --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/scrollable-hidden-3d-transform-z.html @@ -0,0 +1,26 @@ + + + + + +
+
+
+
+
diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/scrollable-scroll-3d-transform-z.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/scrollable-scroll-3d-transform-z.html new file mode 100644 index 0000000000000..dee0251e8184b --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/scrollable-scroll-3d-transform-z.html @@ -0,0 +1,26 @@ + + + + + +
+
+
+
+
\ No newline at end of file diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-001.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-001.html new file mode 100644 index 0000000000000..06a795e0aec9e --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-001.html @@ -0,0 +1,22 @@ + + + + CSS Test (Transforms): Simple Sorting + + + + + + + + +
+
+
+
+ + diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-002.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-002.html new file mode 100644 index 0000000000000..b2fbf455c636f --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-002.html @@ -0,0 +1,24 @@ + + + + CSS Test (Transforms): Simple Sorting With Rotation + + + + + + + + +
+
+
+
+ + diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-003.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-003.html new file mode 100644 index 0000000000000..b015355feff0a --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-003.html @@ -0,0 +1,19 @@ + + + + CSS Test (Transforms): Simple Sorting With No Preserve-3D + + + + + + + +
+
+ + diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-004.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-004.html new file mode 100644 index 0000000000000..b5bbf295768ee --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-004.html @@ -0,0 +1,26 @@ + + + + CSS Test (Transforms): Simple Sorting With Preserve-3D on + Grandparent + + + + + + + +
+
+
+
+
+
+ + diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-005.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-005.html new file mode 100644 index 0000000000000..3dc800e2ec656 --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-005.html @@ -0,0 +1,22 @@ + + + + CSS Test (Transforms): Sorting With Background on Parent + + + + + + + + +
+
+
+ + diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-006.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-006.html new file mode 100644 index 0000000000000..09b54a6e03b0e --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/transform3d-sorting-006.html @@ -0,0 +1,21 @@ + + + + CSS Test (Transforms): Sorting With Intersection + + + + + + + + +
+
+
+
+ + diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/ttwf-css-3d-polygon-cycle.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/ttwf-css-3d-polygon-cycle.html new file mode 100644 index 0000000000000..d3eb750112220 --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-transforms/ttwf-css-3d-polygon-cycle.html @@ -0,0 +1,52 @@ + + + + + CSS Transforms Test: 3d transform polygon cycle + + + + + + + + + + +

The test passes if there red is over green, green is over blue and blue is over red.

+
+
+
+
+
+ + diff --git a/Tests/LibWeb/Text/expected/hit_testing/preserve-3d-depth-order.txt b/Tests/LibWeb/Text/expected/hit_testing/preserve-3d-depth-order.txt new file mode 100644 index 0000000000000..6fbe52dce8341 --- /dev/null +++ b/Tests/LibWeb/Text/expected/hit_testing/preserve-3d-depth-order.txt @@ -0,0 +1,13 @@ +Element at (100, 30): front +Element at (100, 150): scene +== Elements at (100, 30) == +
+
+
+ + +== Elements at (100, 150) == +
+
+ + diff --git a/Tests/LibWeb/Text/expected/wpt-import/css/css-transforms/3d-point-mapping-preserve-3d.txt b/Tests/LibWeb/Text/expected/wpt-import/css/css-transforms/3d-point-mapping-preserve-3d.txt new file mode 100644 index 0000000000000..0d37a45214319 --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/css/css-transforms/3d-point-mapping-preserve-3d.txt @@ -0,0 +1,25 @@ +Harness status: OK + +Found 20 tests + +20 Pass +Pass Point mapping through 3D transform hierarchies, hittesting top-left-light-gray +Pass Point mapping through 3D transform hierarchies, hittesting top-left-light-green +Pass Point mapping through 3D transform hierarchies, hittesting top-middle-light-gray +Pass Point mapping through 3D transform hierarchies, hittesting top-middle-light-green +Pass Point mapping through 3D transform hierarchies, hittesting top-middle-medium-blue +Pass Point mapping through 3D transform hierarchies, hittesting top-right-light-gray +Pass Point mapping through 3D transform hierarchies, hittesting top-right-light-green +Pass Point mapping through 3D transform hierarchies, hittesting top-right-medium-blue +Pass Point mapping through 3D transform hierarchies, hittesting bottom-left-light-gray +Pass Point mapping through 3D transform hierarchies, hittesting bottom-left-light-green +Pass Point mapping through 3D transform hierarchies, hittesting bottom-left-light-yellow +Pass Point mapping through 3D transform hierarchies, hittesting bottom-middle-light-gray +Pass Point mapping through 3D transform hierarchies, hittesting bottom-middle-light-green +Pass Point mapping through 3D transform hierarchies, hittesting bottom-middle-light-yellow +Pass Point mapping through 3D transform hierarchies, hittesting bottom-middle-medium-blue +Pass Point mapping through 3D transform hierarchies, hittesting bottom-right-light-gray +Pass Point mapping through 3D transform hierarchies, hittesting bottom-right-light-green +Pass Point mapping through 3D transform hierarchies, hittesting bottom-right-light-yellow +Pass Point mapping through 3D transform hierarchies, hittesting bottom-right-light-rose +Pass Point mapping through 3D transform hierarchies, hittesting bottom-right-medium-blue \ No newline at end of file diff --git a/Tests/LibWeb/Text/input/hit_testing/preserve-3d-depth-order.html b/Tests/LibWeb/Text/input/hit_testing/preserve-3d-depth-order.html new file mode 100644 index 0000000000000..8cc54380d70a5 --- /dev/null +++ b/Tests/LibWeb/Text/input/hit_testing/preserve-3d-depth-order.html @@ -0,0 +1,49 @@ + + + +
+
+
+
+ diff --git a/Tests/LibWeb/Text/input/wpt-import/css/css-transforms/3d-point-mapping-preserve-3d.html b/Tests/LibWeb/Text/input/wpt-import/css/css-transforms/3d-point-mapping-preserve-3d.html new file mode 100644 index 0000000000000..b10a0718b5043 --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/css/css-transforms/3d-point-mapping-preserve-3d.html @@ -0,0 +1,381 @@ + +Point mapping through 3D transform hierarchies + + + + + + + +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + +