From f2239d248a3175c5fba8f21d1f48d3ccbf84e303 Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 1 Sep 2026 13:05:05 -0400 Subject: [PATCH] Optimize finalized surface artwork --- docs/DEVELOPMENT.md | 29 ++ drawing/surface_drawing_canvas.gd | 388 +++++++++----- drawing/surface_drawing_cell_buffer.gd | 284 +++++++++++ drawing/surface_drawing_cell_buffer.gd.uid | 1 + drawing/surface_drawing_palette.gd | 25 + drawing/surface_drawing_placement.gd | 2 +- drawing/surface_drawing_protocol.gd | 135 +++-- network/network_protocol.gd | 2 +- network/network_surface_drawing_service.gd | 476 +++++++++++++----- scripts/run_validations.sh | 1 + .../surface_drawing_performance_validation.gd | 178 +++++++ ...face_drawing_performance_validation.gd.uid | 1 + ...ace_drawing_texture_renderer_validation.gd | 10 + tests/surface_drawing_validation.gd | 27 +- 14 files changed, 1254 insertions(+), 305 deletions(-) create mode 100644 drawing/surface_drawing_cell_buffer.gd create mode 100644 drawing/surface_drawing_cell_buffer.gd.uid create mode 100644 tests/surface_drawing_performance_validation.gd create mode 100644 tests/surface_drawing_performance_validation.gd.uid diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ef0e161..a5ebc03 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -284,6 +284,35 @@ godot --headless --path . --script scripts/normalize_texture_imports.gd \ godot --headless --path . --script tests/texture_sampling_validation.gd ``` +### Surface artwork runtime + +Art Kit cells are authoritative palette and author planes, not per-cell nodes +or dictionaries. Keep `SurfaceDrawingCellBuffer` as the live representation; +materialize the legacy cell-array view only for inspection and tests. Finished +artwork uses a full-resolution nearest-neighbor texture on a coarse, +surface-conforming mesh. It discards guide and brush transforms, receives a +distance visibility range, and unloads its render node when far from the local +player while retaining compact session state. + +`surface_drawing_v3` snapshots carry packed palette and participant-index +planes. The host sends late-join snapshots over multiple frames, prioritizing +active and nearby canvases, so adding finished work must not restore a +single-frame snapshot burst. Active editing and finalized artwork have +separate bounded budgets; finalizing a canvas frees active-grid capacity. + +The finalized budget holds 512 maximum-size (128 x 128) canvases. At the +current 0.075-meter cell size, 441 canvases can tile the full 200 x 200-meter +generated-world footprint. If the generated world grows, update the finalized +canvas and cell budgets together and rerun the artwork performance validation. + +Run both focused validations after changing this path: + +```sh +godot --headless --path . --script tests/surface_drawing_validation.gd +godot --headless --path . \ + --script tests/surface_drawing_performance_validation.gd +``` + ### Importing animalese voice sets Animalese clips use one deterministic runtime level and format: -18 dBFS RMS diff --git a/drawing/surface_drawing_canvas.gd b/drawing/surface_drawing_canvas.gd index 01d34d7..aaec6f4 100644 --- a/drawing/surface_drawing_canvas.gd +++ b/drawing/surface_drawing_canvas.gd @@ -5,6 +5,9 @@ const GUIDED_PIXEL_FILL: float = 0.88 const SURFACE_OFFSET: float = 0.012 const LAYER_OFFSET_STEP: float = 0.0002 const SURFACE_SAMPLE_DEPTH: float = 0.45 +const FINALIZED_VISIBILITY_RANGE: float = 96.0 +const FINALIZED_VISIBILITY_MARGIN: float = 12.0 +const FINALIZED_MAX_SUBDIVISIONS: int = 32 var canvas_id: String = "" var grid_width: int = 0 @@ -18,7 +21,7 @@ var _surface_origin: Vector3 var _surface_normal: Vector3 var _surface_tangent: Vector3 var _surface_bitangent: Vector3 -var _cells: Dictionary[int, Dictionary] = {} +var _cell_buffer: SurfaceDrawingCellBuffer var _cell_surface_transforms: Array[Transform3D] = [] var _pixel_image: Image var _pixel_texture: ImageTexture @@ -36,8 +39,15 @@ func setup( data: Dictionary, relationships: PlayerRelationshipStore, solid_surface_mask: int = 1, + cell_buffer: SurfaceDrawingCellBuffer = null, ) -> bool: - if not SurfaceDrawingProtocol.validate_canvas_state(data): + if ( + (cell_buffer == null and not SurfaceDrawingProtocol.validate_canvas_state(data)) + or ( + cell_buffer != null + and not SurfaceDrawingProtocol.validate_canvas_metadata(data) + ) + ): return false canvas_id = str(data["canvas_id"]) grid_width = int(data["width"]) @@ -45,7 +55,6 @@ func setup( cell_size = float(data["cell_size"]) revision = int(data["revision"]) creator_fingerprint = str(data["creator_fingerprint"]) - participant_fingerprints = _participants_from_state(data) _finalized = bool(data.get("finalized", false)) _guide_visible = bool(data.get("guide_visible", true)) and not _finalized _layer = int(data.get("layer", 0)) @@ -64,12 +73,20 @@ func setup( _surface_bitangent = _surface_normal.cross(_surface_tangent).normalized() _relationships = relationships _solid_surface_mask = solid_surface_mask - _cells.clear() - for cell_value: Variant in data["cells"]: - var cell: Dictionary = cell_value - _cells[_cell_key(int(cell["x"]), int(cell["y"]))] = cell.duplicate(true) - _build_grid() - _build_pixel_renderer() + _cell_buffer = cell_buffer + if _cell_buffer == null: + _cell_buffer = SurfaceDrawingCellBuffer.new() + if not _cell_buffer.configure_from_state(data, true): + return false + elif _cell_buffer.width != grid_width or _cell_buffer.height != grid_height: + return false + participant_fingerprints = _cell_buffer.get_participants() + if _finalized: + _build_finalized_pixel_renderer() + else: + var samples: Dictionary = _sample_grid_vertices() + _build_grid(samples) + _build_pixel_renderer(samples) return true @@ -80,16 +97,25 @@ func apply_update(data: Dictionary) -> bool: or int(data["revision"]) <= revision ): return false - for edit_value: Variant in data["edits"]: - var edit: Dictionary = edit_value - var key: int = _cell_key(int(edit["x"]), int(edit["y"])) - if str(edit["color_id"]).is_empty(): - _cells.erase(key) - else: - _cells[key] = edit.duplicate(true) - participant_fingerprints = _participants_from_update( - data, participant_fingerprints - ) + if _cell_buffer == null or not _cell_buffer.apply_edits(data["edits"]): + return false + return _finish_buffered_update(data) + + +func apply_buffered_update(data: Dictionary) -> bool: + if ( + not SurfaceDrawingProtocol.validate_canvas_update(data) + or str(data["canvas_id"]) != canvas_id + or int(data["revision"]) <= revision + or _cell_buffer == null + ): + return false + return _finish_buffered_update(data) + + +func _finish_buffered_update(data: Dictionary) -> bool: + _cell_buffer.ensure_participants(data.get("participant_fingerprints", [])) + participant_fingerprints = _cell_buffer.get_participants() revision = int(data["revision"]) if is_hidden_by_relationship(): _rebuild_pixel_image() @@ -110,12 +136,18 @@ func apply_guide_update(data: Dictionary) -> bool: participant_fingerprints = _participants_from_update( data, participant_fingerprints ) + if _cell_buffer != null: + _cell_buffer.ensure_participants(participant_fingerprints) + participant_fingerprints = _cell_buffer.get_participants() _finalized = bool(data["finalized"]) _guide_visible = bool(data["guide_visible"]) and not _finalized if _finalized: _destroy_grid() + _cell_surface_transforms.clear() + _build_finalized_pixel_renderer() else: _refresh_grid_visibility() + _configure_render_distance() return true @@ -155,11 +187,7 @@ func get_rendered_pixel_size() -> float: func get_rendered_pixel_count() -> int: if is_hidden_by_relationship(): return 0 - var count: int = 0 - for cell: Dictionary in _cells.values(): - if not _visible_cell_color(cell).is_equal_approx(Color.TRANSPARENT): - count += 1 - return count + return _cell_buffer.get_painted_count() if _cell_buffer != null else 0 func get_export_image() -> Image: @@ -255,18 +283,10 @@ func get_cell_surface_transform( func get_authoritative_cells() -> Array[Dictionary]: - var result: Array[Dictionary] = [] - for value: Dictionary in _cells.values(): - result.append(value.duplicate(true)) - result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - var a_key: int = _cell_key(int(a["x"]), int(a["y"])) - var b_key: int = _cell_key(int(b["x"]), int(b["y"])) - return a_key < b_key - ) - return result + return _cell_buffer.to_cells() if _cell_buffer != null else [] -func _build_grid() -> void: +func _build_grid(samples: Dictionary) -> void: if _grid_instance != null: _grid_instance.queue_free() _grid_instance = null @@ -279,50 +299,14 @@ func _build_grid() -> void: material.albedo_color = Color(0.75, 0.94, 0.96, 0.38) material.no_depth_test = false immediate.surface_begin(Mesh.PRIMITIVE_LINES, material) - var half_width: float = float(grid_width) * cell_size * 0.5 - var half_height: float = float(grid_height) * cell_size * 0.5 - var sampled_vertices: Dictionary[Vector2i, Dictionary] = {} for x: int in range(grid_width + 1): - var horizontal: float = -half_width + float(x) * cell_size for y_segment: int in range(grid_height): - var start_vertical: float = ( - -half_height + float(y_segment) * cell_size - ) - _add_grid_vertex( - immediate, - _surface_origin + _surface_tangent * horizontal - + _surface_bitangent * start_vertical, - Vector2i(x, y_segment), - sampled_vertices, - ) - _add_grid_vertex( - immediate, - _surface_origin + _surface_tangent * horizontal - + _surface_bitangent * (start_vertical + cell_size), - Vector2i(x, y_segment + 1), - sampled_vertices, - ) + _add_grid_vertex(immediate, x, y_segment, samples) + _add_grid_vertex(immediate, x, y_segment + 1, samples) for y: int in range(grid_height + 1): - var vertical: float = -half_height + float(y) * cell_size for x_segment: int in range(grid_width): - var start_horizontal: float = ( - -half_width + float(x_segment) * cell_size - ) - _add_grid_vertex( - immediate, - _surface_origin + _surface_tangent * start_horizontal - + _surface_bitangent * vertical, - Vector2i(x_segment, y), - sampled_vertices, - ) - _add_grid_vertex( - immediate, - _surface_origin - + _surface_tangent * (start_horizontal + cell_size) - + _surface_bitangent * vertical, - Vector2i(x_segment + 1, y), - sampled_vertices, - ) + _add_grid_vertex(immediate, x_segment, y, samples) + _add_grid_vertex(immediate, x_segment + 1, y, samples) immediate.surface_end() _grid_instance = MeshInstance3D.new() _grid_instance.name = "PixelGrid" @@ -334,63 +318,56 @@ func _build_grid() -> void: func _add_grid_vertex( immediate: ImmediateMesh, - world_point: Vector3, - grid_point: Vector2i, - sampled_vertices: Dictionary[Vector2i, Dictionary], + x: int, + y: int, + samples: Dictionary, ) -> void: - var sampled: Dictionary = sampled_vertices.get(grid_point, {}) - if sampled.is_empty(): - sampled = _sample_surface(world_point) - sampled_vertices[grid_point] = sampled - var point: Vector3 = sampled["position"] - var normal: Vector3 = sampled["normal"] immediate.surface_add_vertex( - to_local(point + normal * (_surface_offset() * 1.5)) + _sample_position(samples, x, y, _surface_offset() * 1.5) ) -func _build_pixel_renderer() -> void: +func _build_pixel_renderer(samples: Dictionary) -> void: if _pixel_instance != null: _pixel_instance.queue_free() _pixel_instance = null + var vertex_width: int = grid_width + 1 + var vertex_count: int = vertex_width * (grid_height + 1) var cell_count: int = grid_width * grid_height var vertices := PackedVector3Array() var texture_coordinates := PackedVector2Array() var indices := PackedInt32Array() - vertices.resize(cell_count * 4) - texture_coordinates.resize(cell_count * 4) + vertices.resize(vertex_count) + texture_coordinates.resize(vertex_count) indices.resize(cell_count * 6) _cell_surface_transforms.clear() - _cell_surface_transforms.resize(cell_count) + if not _finalized: + _cell_surface_transforms.resize(cell_count) + for y: int in range(grid_height + 1): + for x: int in range(grid_width + 1): + var vertex_index: int = y * vertex_width + x + vertices[vertex_index] = _sample_position( + samples, x, y, _surface_offset() + ) + texture_coordinates[vertex_index] = Vector2( + float(x) / float(grid_width), + 1.0 - float(y) / float(grid_height), + ) for y: int in range(grid_height): for x: int in range(grid_width): var cell_index: int = _cell_key(x, y) - var surface_transform := _sample_cell_transform(x, y, 1.0) - _cell_surface_transforms[cell_index] = surface_transform - var center: Vector3 = surface_transform.origin - var horizontal: Vector3 = surface_transform.basis.x * 0.5 - var vertical: Vector3 = surface_transform.basis.y * 0.5 - var vertex_offset: int = cell_index * 4 - vertices[vertex_offset] = center - horizontal - vertical - vertices[vertex_offset + 1] = center + horizontal - vertical - vertices[vertex_offset + 2] = center + horizontal + vertical - vertices[vertex_offset + 3] = center - horizontal + vertical - var image_row: int = _image_y(y) - var u_min: float = float(x) / float(grid_width) - var u_max: float = float(x + 1) / float(grid_width) - var v_min: float = float(image_row) / float(grid_height) - var v_max: float = float(image_row + 1) / float(grid_height) - texture_coordinates[vertex_offset] = Vector2(u_min, v_max) - texture_coordinates[vertex_offset + 1] = Vector2(u_max, v_max) - texture_coordinates[vertex_offset + 2] = Vector2(u_max, v_min) - texture_coordinates[vertex_offset + 3] = Vector2(u_min, v_min) + if not _finalized: + _cell_surface_transforms[cell_index] = _transform_from_samples( + samples, x, y + ) + var vertex_offset: int = y * vertex_width + x var index_offset: int = cell_index * 6 indices[index_offset] = vertex_offset indices[index_offset + 1] = vertex_offset + 1 - indices[index_offset + 2] = vertex_offset + 2 + indices[index_offset + 2] = vertex_offset + vertex_width + 1 indices[index_offset + 3] = vertex_offset - indices[index_offset + 4] = vertex_offset + 2 - indices[index_offset + 5] = vertex_offset + 3 + indices[index_offset + 4] = vertex_offset + vertex_width + 1 + indices[index_offset + 5] = vertex_offset + vertex_width _rebuild_pixel_image() var arrays: Array = [] arrays.resize(Mesh.ARRAY_MAX) @@ -414,21 +391,103 @@ func _build_pixel_renderer() -> void: _pixel_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(_pixel_instance) _pixel_instance.visible = not is_hidden_by_relationship() + _configure_render_distance() + + +func _build_finalized_pixel_renderer() -> void: + if _pixel_instance != null: + _pixel_instance.queue_free() + _pixel_instance = null + var step: int = maxi( + 1, + ceili(float(grid_width) / float(FINALIZED_MAX_SUBDIVISIONS)), + ) + var coordinates := PackedInt32Array() + var coordinate: int = 0 + while coordinate < grid_width: + coordinates.append(coordinate) + coordinate += step + if coordinates.is_empty() or coordinates[-1] != grid_width: + coordinates.append(grid_width) + var vertex_width: int = coordinates.size() + var vertices := PackedVector3Array() + var texture_coordinates := PackedVector2Array() + var indices := PackedInt32Array() + vertices.resize(vertex_width * vertex_width) + texture_coordinates.resize(vertex_width * vertex_width) + indices.resize((vertex_width - 1) * (vertex_width - 1) * 6) + var half_width: float = float(grid_width) * cell_size * 0.5 + var half_height: float = float(grid_height) * cell_size * 0.5 + for lattice_y: int in vertex_width: + var cell_y: int = coordinates[lattice_y] + var vertical: float = -half_height + float(cell_y) * cell_size + for lattice_x: int in vertex_width: + var cell_x: int = coordinates[lattice_x] + var horizontal: float = -half_width + float(cell_x) * cell_size + var expected: Vector3 = ( + _surface_origin + + _surface_tangent * horizontal + + _surface_bitangent * vertical + ) + var sampled: Dictionary = _sample_surface(expected) + var vertex_index: int = lattice_y * vertex_width + lattice_x + var point: Vector3 = sampled["position"] + var normal: Vector3 = sampled["normal"] + vertices[vertex_index] = to_local( + point + normal * _surface_offset() + ) + texture_coordinates[vertex_index] = Vector2( + float(cell_x) / float(grid_width), + 1.0 - float(cell_y) / float(grid_height), + ) + for lattice_y: int in range(vertex_width - 1): + for lattice_x: int in range(vertex_width - 1): + var cell_index: int = lattice_y * (vertex_width - 1) + lattice_x + var vertex_offset: int = lattice_y * vertex_width + lattice_x + var index_offset: int = cell_index * 6 + indices[index_offset] = vertex_offset + indices[index_offset + 1] = vertex_offset + 1 + indices[index_offset + 2] = vertex_offset + vertex_width + 1 + indices[index_offset + 3] = vertex_offset + indices[index_offset + 4] = vertex_offset + vertex_width + 1 + indices[index_offset + 5] = vertex_offset + vertex_width + if _pixel_image == null or _pixel_texture == null: + _rebuild_pixel_image() + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_TEX_UV] = texture_coordinates + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = Color.WHITE + material.albedo_texture = _pixel_texture + material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR + material.alpha_scissor_threshold = 0.5 + material.cull_mode = BaseMaterial3D.CULL_DISABLED + mesh.surface_set_material(0, material) + _pixel_instance = MeshInstance3D.new() + _pixel_instance.name = "ArtworkTexture" + _pixel_instance.mesh = mesh + _pixel_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(_pixel_instance) + _pixel_instance.visible = not is_hidden_by_relationship() + _configure_render_distance() func _rebuild_pixel_image() -> void: - if ( - _pixel_image == null - or _pixel_image.get_width() != grid_width - or _pixel_image.get_height() != grid_height - ): - _pixel_image = Image.create( - grid_width, grid_height, false, Image.FORMAT_RGBA8 - ) - _pixel_image.fill(Color.TRANSPARENT) - if not is_hidden_by_relationship(): - for cell: Dictionary in _cells.values(): - _set_image_cell(int(cell["x"]), int(cell["y"]), cell) + if _cell_buffer == null: + return + _pixel_image = Image.create_from_data( + grid_width, + grid_height, + false, + Image.FORMAT_RGBA8, + _cell_buffer.to_rgba8_data(is_hidden_by_relationship()), + ) if _pixel_texture == null: _pixel_texture = ImageTexture.create_from_image(_pixel_image) else: @@ -443,7 +502,7 @@ func _apply_pixel_edits(edits: Array) -> void: var edit: Dictionary = edit_value var x: int = int(edit["x"]) var y: int = int(edit["y"]) - var cell: Dictionary = _cells.get(_cell_key(x, y), {}) + var cell: Dictionary = _cell_buffer.get_cell(x, y) _set_image_cell(x, y, cell) _pixel_texture.update(_pixel_image) @@ -475,6 +534,78 @@ func _image_y(cell_y: int) -> int: return grid_height - 1 - cell_y +func _sample_grid_vertices() -> Dictionary: + var vertex_width: int = grid_width + 1 + var vertex_count: int = vertex_width * (grid_height + 1) + var positions := PackedVector3Array() + var normals := PackedVector3Array() + positions.resize(vertex_count) + normals.resize(vertex_count) + var half_width: float = float(grid_width) * cell_size * 0.5 + var half_height: float = float(grid_height) * cell_size * 0.5 + var inverse_basis: Basis = global_basis.inverse() + for y: int in range(grid_height + 1): + var vertical: float = -half_height + float(y) * cell_size + for x: int in range(grid_width + 1): + var horizontal: float = -half_width + float(x) * cell_size + var expected: Vector3 = ( + _surface_origin + + _surface_tangent * horizontal + + _surface_bitangent * vertical + ) + var sampled: Dictionary = _sample_surface(expected) + var index: int = y * vertex_width + x + positions[index] = to_local(sampled["position"]) + normals[index] = ( + inverse_basis * (sampled["normal"] as Vector3) + ).normalized() + return {"positions": positions, "normals": normals} + + +func _sample_position( + samples: Dictionary, + x: int, + y: int, + offset: float, +) -> Vector3: + var index: int = y * (grid_width + 1) + x + var positions: PackedVector3Array = samples["positions"] + var normals: PackedVector3Array = samples["normals"] + return positions[index] + normals[index] * offset + + +func _transform_from_samples( + samples: Dictionary, + x: int, + y: int, +) -> Transform3D: + var bottom_left: Vector3 = _sample_position( + samples, x, y, _surface_offset() + ) + var bottom_right: Vector3 = _sample_position( + samples, x + 1, y, _surface_offset() + ) + var top_left: Vector3 = _sample_position( + samples, x, y + 1, _surface_offset() + ) + var top_right: Vector3 = _sample_position( + samples, x + 1, y + 1, _surface_offset() + ) + var horizontal: Vector3 = ( + (bottom_right - bottom_left) + (top_right - top_left) + ) * 0.5 + var vertical: Vector3 = ( + (top_left - bottom_left) + (top_right - bottom_right) + ) * 0.5 + var normal: Vector3 = horizontal.cross(vertical).normalized() + if horizontal.is_zero_approx() or vertical.is_zero_approx() or normal.is_zero_approx(): + return _sample_cell_transform(x, y, 1.0) + var center: Vector3 = ( + bottom_left + bottom_right + top_left + top_right + ) * 0.25 + return Transform3D(Basis(horizontal, vertical, normal), center) + + func _sample_cell_transform( x: int, y: int, @@ -549,6 +680,17 @@ func _refresh_grid_visibility() -> void: ) +func _configure_render_distance() -> void: + if _pixel_instance == null: + return + _pixel_instance.visibility_range_end = ( + FINALIZED_VISIBILITY_RANGE if _finalized else 0.0 + ) + _pixel_instance.visibility_range_end_margin = ( + FINALIZED_VISIBILITY_MARGIN if _finalized else 0.0 + ) + + func _participants_from_state(data: Dictionary) -> Array[String]: var result: Array[String] = [] _append_participant(result, str(data.get("creator_fingerprint", ""))) diff --git a/drawing/surface_drawing_cell_buffer.gd b/drawing/surface_drawing_cell_buffer.gd new file mode 100644 index 0000000..ca2e7b3 --- /dev/null +++ b/drawing/surface_drawing_cell_buffer.gd @@ -0,0 +1,284 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +class_name SurfaceDrawingCellBuffer +extends RefCounted + +const ENCODING := "palette_author_planes_v1" + +var width: int = 0 +var height: int = 0 + +var _color_indices := PackedByteArray() +var _author_indices := PackedInt32Array() +var _participants: Array[String] = [] +var _participant_slots: Dictionary[String, int] = {} +var _author_masks: Dictionary[int, PackedByteArray] = {} +var _painted_count: int = 0 + + +func configure_from_state( + data: Dictionary, + already_validated: bool = false, +) -> bool: + if not already_validated and not SurfaceDrawingProtocol.validate_canvas_state(data): + return false + width = int(data["width"]) + height = int(data["height"]) + _reset_planes() + for value: Variant in data.get("participant_fingerprints", []): + _add_participant(str(value)) + _add_participant(str(data.get("creator_fingerprint", ""))) + if str(data.get("cell_encoding", "")) == ENCODING: + return _configure_packed( + data.get("cell_colors", PackedByteArray()), + data.get("cell_authors", PackedInt32Array()), + ) + for value: Variant in data.get("cells", []): + if typeof(value) != TYPE_DICTIONARY or not _apply_edit(value): + return false + return true + + +func apply_edits(edits: Array) -> bool: + for value: Variant in edits: + if typeof(value) != TYPE_DICTIONARY or not _apply_edit(value): + return false + return true + + +func ensure_participants(values: Array) -> bool: + for value: Variant in values: + if _add_participant(str(value)) <= 0: + return false + return true + + +func get_cell(x: int, y: int) -> Dictionary: + var key: int = _key(x, y) + if key < 0 or _color_indices[key] == 0: + return {} + var author_slot: int = _author_indices[key] + return { + "x": x, + "y": y, + "color_id": str( + SurfaceDrawingPalette.id_for_palette_index(_color_indices[key]) + ), + "author_fingerprint": ( + _participants[author_slot - 1] + if author_slot > 0 and author_slot <= _participants.size() + else "" + ), + } + + +func get_cell_value(x: int, y: int) -> Dictionary: + var cell: Dictionary = get_cell(x, y) + if cell.is_empty(): + return {} + return { + "color_id": cell["color_id"], + "author_fingerprint": cell["author_fingerprint"], + } + + +func get_painted_count() -> int: + return _painted_count + + +func get_participants() -> Array[String]: + return _participants.duplicate() + + +func get_author_cell_keys(fingerprint: String) -> PackedInt32Array: + var slot: int = int(_participant_slots.get(fingerprint, 0)) + var mask: PackedByteArray = _author_masks.get(slot, PackedByteArray()) + var result := PackedInt32Array() + if slot <= 0 or mask.is_empty(): + return result + for key: int in width * height: + if _mask_has(mask, key): + result.append(key) + return result + + +func to_cells() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for key: int in width * height: + if _color_indices[key] == 0: + continue + result.append(get_cell(key % width, floori(float(key) / float(width)))) + return result + + +func to_compact_fields() -> Dictionary: + return { + "cell_encoding": ENCODING, + "cell_colors": _color_indices.duplicate(), + "cell_authors": _author_indices.duplicate(), + "participant_fingerprints": get_participants(), + } + + +func to_rgba8_data(hidden: bool = false) -> PackedByteArray: + var result := PackedByteArray() + result.resize(width * height * 4) + # Match Image.fill(Color.TRANSPARENT): transparent texels retain white RGB, + # preventing dark fringes if filtering is ever changed by a platform driver. + for pixel_index: int in width * height: + var offset: int = pixel_index * 4 + result[offset] = 255 + result[offset + 1] = 255 + result[offset + 2] = 255 + if hidden: + return result + for key: int in width * height: + var palette_index: int = _color_indices[key] + if palette_index == 0: + continue + var x: int = key % width + var y: int = floori(float(key) / float(width)) + var image_key: int = (height - 1 - y) * width + x + var offset: int = image_key * 4 + var color: Color = SurfaceDrawingPalette.color_for_palette_index( + palette_index + ) + result[offset] = roundi(color.r * 255.0) + result[offset + 1] = roundi(color.g * 255.0) + result[offset + 2] = roundi(color.b * 255.0) + result[offset + 3] = roundi(color.a * 255.0) + return result + + +func estimated_storage_bytes() -> int: + var masks_size: int = 0 + for mask: PackedByteArray in _author_masks.values(): + masks_size += mask.size() + return _color_indices.size() + _author_indices.size() * 4 + masks_size + + +func _configure_packed(colors: Variant, authors: Variant) -> bool: + if ( + typeof(colors) != TYPE_PACKED_BYTE_ARRAY + or typeof(authors) != TYPE_PACKED_INT32_ARRAY + ): + return false + var packed_colors: PackedByteArray = colors + var packed_authors: PackedInt32Array = authors + if ( + packed_colors.size() != width * height + or packed_authors.size() != width * height + ): + return false + _color_indices = packed_colors.duplicate() + _author_indices = packed_authors.duplicate() + _painted_count = 0 + for key: int in width * height: + var color_index: int = _color_indices[key] + var author_slot: int = _author_indices[key] + if color_index == 0: + if author_slot != 0: + return false + continue + if ( + color_index > SurfaceDrawingPalette.COLORS.size() + or author_slot <= 0 + or author_slot > _participants.size() + ): + return false + _painted_count += 1 + _set_author_mask_bit(author_slot, key, true) + return true + + +func _apply_edit(value: Dictionary) -> bool: + var x: int = int(value.get("x", -1)) + var y: int = int(value.get("y", -1)) + var key: int = _key(x, y) + if key < 0: + return false + var color_id := StringName(str(value.get("color_id", ""))) + var next_color_index: int = ( + SurfaceDrawingPalette.palette_index_for_id(color_id) + if not color_id.is_empty() + else 0 + ) + if not color_id.is_empty() and next_color_index <= 0: + return false + var next_author_slot: int = 0 + if next_color_index > 0: + next_author_slot = _add_participant( + str(value.get("author_fingerprint", "")) + ) + if next_author_slot <= 0: + return false + var previous_color_index: int = _color_indices[key] + var previous_author_slot: int = _author_indices[key] + if previous_color_index == next_color_index and previous_author_slot == next_author_slot: + return true + if previous_author_slot > 0: + _set_author_mask_bit(previous_author_slot, key, false) + if previous_color_index == 0 and next_color_index > 0: + _painted_count += 1 + elif previous_color_index > 0 and next_color_index == 0: + _painted_count -= 1 + _color_indices[key] = next_color_index + _author_indices[key] = next_author_slot + if next_author_slot > 0: + _set_author_mask_bit(next_author_slot, key, true) + return true + + +func _reset_planes() -> void: + _color_indices.resize(width * height) + _color_indices.fill(0) + _author_indices.resize(width * height) + _author_indices.fill(0) + _participants.clear() + _participant_slots.clear() + _author_masks.clear() + _painted_count = 0 + + +func _add_participant(fingerprint: String) -> int: + var existing: int = int(_participant_slots.get(fingerprint, 0)) + if existing > 0: + return existing + if not NetworkIdentityCrypto.valid_fingerprint(fingerprint): + return 0 + if _participants.size() >= SurfaceDrawingProtocol.MAX_PARTICIPANTS: + return 0 + _participants.append(fingerprint) + var slot: int = _participants.size() + _participant_slots[fingerprint] = slot + return slot + + +func _set_author_mask_bit(slot: int, key: int, enabled: bool) -> void: + if slot <= 0 or key < 0: + return + var mask: PackedByteArray = _author_masks.get(slot, PackedByteArray()) + var required_size: int = ceili(float(width * height) / 8.0) + if mask.size() != required_size: + mask.resize(required_size) + var byte_index: int = floori(float(key) / 8.0) + var bit: int = 1 << (key % 8) + if enabled: + mask[byte_index] = mask[byte_index] | bit + else: + mask[byte_index] = mask[byte_index] & (~bit & 0xff) + _author_masks[slot] = mask + + +func _mask_has(mask: PackedByteArray, key: int) -> bool: + var byte_index: int = floori(float(key) / 8.0) + return ( + byte_index >= 0 + and byte_index < mask.size() + and (mask[byte_index] & (1 << (key % 8))) != 0 + ) + + +func _key(x: int, y: int) -> int: + if x < 0 or x >= width or y < 0 or y >= height: + return -1 + return y * width + x diff --git a/drawing/surface_drawing_cell_buffer.gd.uid b/drawing/surface_drawing_cell_buffer.gd.uid new file mode 100644 index 0000000..1910bdf --- /dev/null +++ b/drawing/surface_drawing_cell_buffer.gd.uid @@ -0,0 +1 @@ +uid://b4clw478edbnk diff --git a/drawing/surface_drawing_palette.gd b/drawing/surface_drawing_palette.gd index c8bfc8e..4a3b734 100644 --- a/drawing/surface_drawing_palette.gd +++ b/drawing/surface_drawing_palette.gd @@ -45,6 +45,31 @@ static func get_color_ids() -> Array[StringName]: return result +static func palette_index_for_id(color_id: StringName) -> int: + for index: int in COLORS.size(): + if StringName(COLORS[index].get("id", &"")) == color_id: + return index + 1 + return 0 + + +static func id_for_palette_index(palette_index: int) -> StringName: + var index: int = palette_index - 1 + return ( + StringName(COLORS[index].get("id", &"")) + if index >= 0 and index < COLORS.size() + else StringName() + ) + + +static func color_for_palette_index(palette_index: int) -> Color: + var index: int = palette_index - 1 + return ( + COLORS[index].get("color", Color.TRANSPARENT) + if index >= 0 and index < COLORS.size() + else Color.TRANSPARENT + ) + + static func filter_unlocked_ids( unlocked_ids: Array[StringName], ) -> Array[StringName]: diff --git a/drawing/surface_drawing_placement.gd b/drawing/surface_drawing_placement.gd index bfa1abc..7fb0e25 100644 --- a/drawing/surface_drawing_placement.gd +++ b/drawing/surface_drawing_placement.gd @@ -25,7 +25,7 @@ static func resolve( } var nearest_distance: float = SNAP_DISTANCE for state: Dictionary in canvas_states: - if not SurfaceDrawingProtocol.validate_canvas_state(state): + if not SurfaceDrawingProtocol.validate_canvas_metadata(state): continue var anchor_normal: Vector3 = SurfaceDrawingProtocol.array_to_vector( state["normal"] diff --git a/drawing/surface_drawing_protocol.gd b/drawing/surface_drawing_protocol.gd index ff132f8..b31d9c0 100644 --- a/drawing/surface_drawing_protocol.gd +++ b/drawing/surface_drawing_protocol.gd @@ -1,7 +1,7 @@ class_name SurfaceDrawingProtocol extends RefCounted -const CAPABILITY: StringName = &"surface_drawing_v2" +const CAPABILITY: StringName = &"surface_drawing_v3" const RELIABLE_CHANNEL: int = NetworkProtocol.DRAWING_RELIABLE_CHANNEL const GRID_SIZES: Array[int] = [16, 32, 64, 128] const DEFAULT_GRID_SIZE: int = 16 @@ -11,8 +11,12 @@ const GRID_WIDTH: int = DEFAULT_GRID_SIZE const GRID_HEIGHT: int = DEFAULT_GRID_SIZE const CELL_SIZE: float = 0.075 const MAX_ACTIVE_CANVASES: int = 24 -const MAX_CANVASES: int = 48 -const MAX_SESSION_GRID_CELLS: int = 49152 +const MAX_FINALIZED_CANVASES: int = 512 +const MAX_CANVASES: int = MAX_ACTIVE_CANVASES + MAX_FINALIZED_CANVASES +const MAX_ACTIVE_GRID_CELLS: int = 49152 +const MAX_FINALIZED_GRID_CELLS: int = 8 * 1024 * 1024 +# Compatibility alias for code concerned only with live editing capacity. +const MAX_SESSION_GRID_CELLS: int = MAX_ACTIVE_GRID_CELLS const MAX_EDITS_PER_REQUEST: int = 16 const MAX_CANVAS_ID_LENGTH: int = 64 const MAX_REQUEST_ID_LENGTH: int = 64 @@ -22,6 +26,7 @@ const MAX_SESSION_ID_LENGTH: int = 96 # for a complete room turnover while never accepting unattributed edits. const MAX_PARTICIPANTS: int = 256 const MAX_COORDINATE: float = 10000.0 +const CELL_ENCODING := "palette_author_planes_v1" static func validate_canvas_request(data: Variant) -> bool: @@ -122,59 +127,93 @@ static func validate_canvas_state(data: Variant) -> bool: if typeof(data) != TYPE_DICTIONARY: return false var value: Dictionary = data - if ( - typeof(value.get("session_id")) != TYPE_STRING - or str(value["session_id"]).is_empty() - or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH - or typeof(value.get("canvas_id")) != TYPE_STRING - or str(value["canvas_id"]).is_empty() - or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH - or not _valid_vector(value.get("origin")) - or not _valid_vector(value.get("normal")) - or not _valid_vector(value.get("tangent")) - or typeof(value.get("width")) != TYPE_INT - or int(value["width"]) not in GRID_SIZES - or typeof(value.get("height")) != TYPE_INT - or int(value["height"]) != int(value["width"]) - or typeof(value.get("cell_size")) not in [TYPE_FLOAT, TYPE_INT] - or not is_equal_approx(float(value["cell_size"]), CELL_SIZE) - or typeof(value.get("revision")) != TYPE_INT - or int(value["revision"]) < 0 - or typeof(value.get("guide_visible", true)) != TYPE_BOOL - or typeof(value.get("finalized", false)) != TYPE_BOOL - or typeof(value.get("layer", 0)) != TYPE_INT - or int(value.get("layer", 0)) < 0 - or not NetworkIdentityCrypto.valid_fingerprint( - value.get("creator_fingerprint") - ) - or not _valid_participant_fingerprints( - value.get("participant_fingerprints", []) - ) - or typeof(value.get("cells")) != TYPE_ARRAY - ): + if not validate_canvas_metadata(value): return false - var cells: Array = value["cells"] - var grid_width: int = int(value["width"]) - var grid_height: int = int(value["height"]) - if cells.size() > grid_width * grid_height: - return false - for cell_value: Variant in cells: - if ( - not validate_authoritative_cell(cell_value) - or int((cell_value as Dictionary)["x"]) >= grid_width - or int((cell_value as Dictionary)["y"]) >= grid_height - ): - return false var participants: Array = value.get("participant_fingerprints", []) - if not participants.is_empty(): - if str(value["creator_fingerprint"]) not in participants: + if not participants.is_empty() and str(value["creator_fingerprint"]) not in participants: + return false + if typeof(value.get("cells")) == TYPE_ARRAY: + var cells: Array = value["cells"] + var grid_width: int = int(value["width"]) + var grid_height: int = int(value["height"]) + if cells.size() > grid_width * grid_height: return false for cell_value: Variant in cells: - if str((cell_value as Dictionary)["author_fingerprint"]) not in participants: + if ( + not validate_authoritative_cell(cell_value) + or int((cell_value as Dictionary)["x"]) >= grid_width + or int((cell_value as Dictionary)["y"]) >= grid_height + ): return false + if ( + not participants.is_empty() + and str((cell_value as Dictionary)["author_fingerprint"]) + not in participants + ): + return false + return true + if ( + str(value.get("cell_encoding", "")) != CELL_ENCODING + or typeof(value.get("cell_colors")) != TYPE_PACKED_BYTE_ARRAY + or typeof(value.get("cell_authors")) != TYPE_PACKED_INT32_ARRAY + or participants.is_empty() + ): + return false + var colors: PackedByteArray = value["cell_colors"] + var authors: PackedInt32Array = value["cell_authors"] + var expected_size: int = int(value["width"]) * int(value["height"]) + if colors.size() != expected_size or authors.size() != expected_size: + return false + for index: int in expected_size: + var color_index: int = colors[index] + var author_slot: int = authors[index] + if color_index == 0: + if author_slot != 0: + return false + elif ( + color_index > SurfaceDrawingPalette.COLORS.size() + or author_slot <= 0 + or author_slot > participants.size() + ): + return false return true +static func validate_canvas_metadata(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + typeof(value.get("session_id")) == TYPE_STRING + and not str(value["session_id"]).is_empty() + and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH + and typeof(value.get("canvas_id")) == TYPE_STRING + and not str(value["canvas_id"]).is_empty() + and str(value["canvas_id"]).length() <= MAX_CANVAS_ID_LENGTH + and _valid_vector(value.get("origin")) + and _valid_vector(value.get("normal")) + and _valid_vector(value.get("tangent")) + and typeof(value.get("width")) == TYPE_INT + and int(value["width"]) in GRID_SIZES + and typeof(value.get("height")) == TYPE_INT + and int(value["height"]) == int(value["width"]) + and typeof(value.get("cell_size")) in [TYPE_FLOAT, TYPE_INT] + and is_equal_approx(float(value["cell_size"]), CELL_SIZE) + and typeof(value.get("revision")) == TYPE_INT + and int(value["revision"]) >= 0 + and typeof(value.get("guide_visible", true)) == TYPE_BOOL + and typeof(value.get("finalized", false)) == TYPE_BOOL + and typeof(value.get("layer", 0)) == TYPE_INT + and int(value.get("layer", 0)) >= 0 + and NetworkIdentityCrypto.valid_fingerprint( + value.get("creator_fingerprint") + ) + and _valid_participant_fingerprints( + value.get("participant_fingerprints", []) + ) + ) + + static func validate_guide_update(data: Variant) -> bool: if typeof(data) != TYPE_DICTIONARY: return false diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 5b3ca89..d994c96 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -32,7 +32,7 @@ const DRAWING_RELIABLE_CHANNEL: int = 14 const FISHING_RELIABLE_CHANNEL: int = 16 const HOME_RELIABLE_CHANNEL: int = 17 const ENET_CHANNEL_COUNT: int = 18 -const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2" +const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v3" const ART_SHOP_CAPABILITY: String = "art_shop_v1" const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1" const WORLD_TIME_CAPABILITY: String = "world_time_v2" diff --git a/network/network_surface_drawing_service.gd b/network/network_surface_drawing_service.gd index 485dfaa..5f6c52e 100644 --- a/network/network_surface_drawing_service.gd +++ b/network/network_surface_drawing_service.gd @@ -31,6 +31,14 @@ const ARTWORK_DIRECTORY_NAME: String = "artwork" const STAMP_ALPHA_THRESHOLD: float = 0.5 const STAMP_COLOR_TOLERANCE: float = 0.012 const INVALID_POINTER_SCREEN_POSITION := Vector2(-1.0, -1.0) +const SNAPSHOT_CANVASES_PER_FRAME: int = 2 +# A maximum-size 128 x 128 canvas consumes this entire budget, preventing +# late-join synchronization from building two large artwork textures at once. +const SNAPSHOT_GRID_CELLS_PER_FRAME: int = 16384 +const FINALIZED_NODE_LOAD_DISTANCE: float = 108.0 +const FINALIZED_NODE_UNLOAD_DISTANCE: float = 128.0 +const FINALIZED_STREAM_INTERVAL_MSEC: int = 250 +const FINALIZED_NODE_LOADS_PER_PASS: int = 1 enum GuideAction { NONE, @@ -49,7 +57,10 @@ var _art_unlocks: PlayerArtUnlocks var _data_root: PlayerDataRoot var _drawing_root: Node3D var _canvas_states: Dictionary[String, Dictionary] = {} +var _canvas_cells: Dictionary[String, SurfaceDrawingCellBuffer] = {} var _canvas_nodes: Dictionary[String, SurfaceDrawingCanvas] = {} +var _snapshot_queues: Dictionary[int, Array] = {} +var _last_finalized_stream_msec: int = 0 var _selected_canvas_id: String = "" var _hovered_canvas_id: String = "" var _last_hovered_canvas_id: String = "" @@ -753,8 +764,8 @@ func get_canvas_count() -> int: func get_painted_cell_count() -> int: var count: int = 0 - for state: Dictionary in _canvas_states.values(): - count += (state.get("cells", []) as Array).size() + for buffer: SurfaceDrawingCellBuffer in _canvas_cells.values(): + count += buffer.get_painted_count() return count @@ -807,7 +818,13 @@ func get_canvas_ids() -> Array[String]: func get_canvas_state(canvas_id: String) -> Dictionary: - return Dictionary(_canvas_states.get(canvas_id, {})).duplicate(true) + var state: Dictionary = _canvas_states.get(canvas_id, {}) + var buffer: SurfaceDrawingCellBuffer = _canvas_cells.get(canvas_id) + if state.is_empty() or buffer == null: + return {} + var result: Dictionary = state.duplicate(true) + result["cells"] = buffer.to_cells() + return result func is_stamp_mode() -> bool: @@ -1029,6 +1046,8 @@ func _next_artwork_export_path(directory: String) -> String: func _process(_delta: float) -> void: + _pump_snapshot_queues() + _refresh_finalized_canvas_streaming() if not _active: return if not can_activate(): @@ -1313,8 +1332,8 @@ func _handle_canvas_request(peer_id: int, data: Dictionary) -> void: or not _peer_can_place_grid(peer_id, requested_size) or _canvas_states.size() >= SurfaceDrawingProtocol.MAX_CANVASES or _active_canvas_count() >= SurfaceDrawingProtocol.MAX_ACTIVE_CANVASES - or _allocated_grid_cells() + requested_size * requested_size - > SurfaceDrawingProtocol.MAX_SESSION_GRID_CELLS + or _allocated_active_grid_cells() + requested_size * requested_size + > SurfaceDrawingProtocol.MAX_ACTIVE_GRID_CELLS ): return var avatar: Player = _spawn_service.get_avatar(peer_id) @@ -1400,8 +1419,10 @@ func _handle_stamp_request(peer_id: int, data: Dictionary) -> void: or not _accept_request(peer_id, str(data["request_id"])) or not _peer_can_stamp(peer_id, data) or _canvas_states.size() >= SurfaceDrawingProtocol.MAX_CANVASES - or _allocated_grid_cells() + requested_size * requested_size - > SurfaceDrawingProtocol.MAX_SESSION_GRID_CELLS + or _finalized_canvas_count() + >= SurfaceDrawingProtocol.MAX_FINALIZED_CANVASES + or _allocated_finalized_grid_cells() + requested_size * requested_size + > SurfaceDrawingProtocol.MAX_FINALIZED_GRID_CELLS ): return var avatar: Player = _spawn_service.get_avatar(peer_id) @@ -1442,6 +1463,12 @@ func _handle_stamp_request(peer_id: int, data: Dictionary) -> void: var canvas_id: String = "%s-%d" % [ _session.get_session_id().left(16), _canvas_sequence, ] + var stamp_colors: PackedByteArray = data["pixels"] + var stamp_authors := PackedInt32Array() + stamp_authors.resize(stamp_colors.size()) + for cell_key: int in stamp_colors.size(): + if stamp_colors[cell_key] > 0: + stamp_authors[cell_key] = 1 var state: Dictionary = { "session_id": _session.get_session_id(), "canvas_id": canvas_id, @@ -1459,9 +1486,9 @@ func _handle_stamp_request(peer_id: int, data: Dictionary) -> void: "layer": _canvas_sequence, "creator_fingerprint": record.identity_fingerprint, "participant_fingerprints": [record.identity_fingerprint], - "cells": _stamp_cells( - data["pixels"], requested_size, record.identity_fingerprint - ), + "cell_encoding": SurfaceDrawingProtocol.CELL_ENCODING, + "cell_colors": stamp_colors, + "cell_authors": stamp_authors, "stamp": true, } _apply_canvas_state(state) @@ -1469,27 +1496,6 @@ func _handle_stamp_request(peer_id: int, data: Dictionary) -> void: _emit_hud_state("stamp placed • finalized artwork shared with everyone") -func _stamp_cells( - pixels: PackedByteArray, - grid_size: int, - author_fingerprint: String, -) -> Array[Dictionary]: - var result: Array[Dictionary] = [] - var palette_ids: Array[StringName] = SurfaceDrawingPalette.get_color_ids() - for cell_key: int in range(pixels.size()): - var palette_index: int = pixels[cell_key] - if palette_index <= 0: - continue - var cell_y: int = floori(float(cell_key) / float(grid_size)) - result.append({ - "x": cell_key % grid_size, - "y": cell_y, - "color_id": str(palette_ids[palette_index - 1]), - "author_fingerprint": author_fingerprint, - }) - return result - - @rpc( "any_peer", "call_remote", @@ -1529,6 +1535,18 @@ func _handle_guide_request(peer_id: int, data: Dictionary) -> void: var finalized: bool = bool(data["finalized"]) if was_finalized: return + if ( + finalized + and ( + _finalized_canvas_count() + >= SurfaceDrawingProtocol.MAX_FINALIZED_CANVASES + or _allocated_finalized_grid_cells() + + int(state["width"]) * int(state["height"]) + > SurfaceDrawingProtocol.MAX_FINALIZED_GRID_CELLS + ) + ): + _emit_hud_state("finished artwork limit reached for this session") + return var guide_visible: bool = bool(data["guide_visible"]) and not finalized if ( bool(state.get("guide_visible", true)) == guide_visible @@ -1774,18 +1792,23 @@ func _queue_overlapping_finished_mutations( ).normalized() if normal.dot(target_normal) < 0.9: continue - var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) if ( - canvas == null - or not canvas.contains_world_point( + not _state_contains_world_point( + state, world_position, SurfaceDrawingCanvas.SURFACE_SAMPLE_DEPTH + 0.05, ) ): continue - var center_cell: Vector2i = canvas.cell_at_world_point(world_position) + var center_cell: Vector2i = _state_cell_at_world_point( + state, world_position + ) if center_cell.x < 0: continue + var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector( + state["tangent"] + ).normalized() + var bitangent: Vector3 = normal.cross(tangent).normalized() for y_offset: int in range(-1, 2): for x_offset: int in range(-1, 2): var cell := Vector2i( @@ -1794,19 +1817,19 @@ func _queue_overlapping_finished_mutations( ) if ( cell.x < 0 - or cell.x >= canvas.grid_width + or cell.x >= int(state["width"]) or cell.y < 0 - or cell.y >= canvas.grid_height + or cell.y >= int(state["height"]) ): continue var relative: Vector3 = ( - canvas.get_cell_world_position(cell.x, cell.y) + _state_cell_position(state, cell.x, cell.y) - world_position ) if ( - absf(relative.dot(canvas.get_surface_tangent())) + absf(relative.dot(tangent)) >= SurfaceDrawingProtocol.CELL_SIZE - or absf(relative.dot(canvas.get_surface_bitangent())) + or absf(relative.dot(bitangent)) >= SurfaceDrawingProtocol.CELL_SIZE or _state_cell(state, cell.x, cell.y).is_empty() ): @@ -1861,11 +1884,9 @@ func _publish_cell_mutations( canvas_ids.sort() for canvas_id: String in canvas_ids: var state: Dictionary = _canvas_states.get(canvas_id, {}) - if state.is_empty(): + var buffer: SurfaceDrawingCellBuffer = _canvas_cells.get(canvas_id) + if state.is_empty() or buffer == null: continue - var cells: Dictionary[int, Dictionary] = _cells_by_key( - state["cells"], int(state["width"]) - ) var canvas_mutations: Dictionary = mutations[canvas_id] var cell_keys: Array[int] = [] for cell_key_value: Variant in canvas_mutations.keys(): @@ -1874,18 +1895,11 @@ func _publish_cell_mutations( var edits: Array[Dictionary] = [] for cell_key: int in cell_keys: var edit: Dictionary = canvas_mutations[cell_key] - if str(edit["color_id"]).is_empty(): - cells.erase(cell_key) - else: - cells[cell_key] = edit.duplicate(true) edits.append(edit.duplicate(true)) - if edits.is_empty(): + if edits.is_empty() or not buffer.apply_edits(edits): continue state["revision"] = int(state["revision"]) + 1 - state["cells"] = _sorted_cells(cells) - state["participant_fingerprints"] = _merged_participants( - state, {"edits": edits} - ) + state["participant_fingerprints"] = buffer.get_participants() _canvas_states[canvas_id] = state var update: Dictionary = { "session_id": _session.get_session_id(), @@ -1898,7 +1912,7 @@ func _publish_cell_mutations( } var local_canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) if local_canvas != null: - local_canvas.apply_update(update) + local_canvas.apply_buffered_update(update) _broadcast_canvas_update(update) _emit_artwork_changed() @@ -2068,8 +2082,13 @@ func request_canvas_snapshot(request_id: String) -> void: ) ): return - for state: Dictionary in _canvas_states.values(): - receive_canvas_state.rpc_id(sender_id, state) + var canvas_ids: Array[String] = get_canvas_ids() + canvas_ids.sort_custom(func(first: String, second: String) -> bool: + return _snapshot_canvas_precedes(first, second, sender_id) + ) + var queued: Array = [] + queued.assign(canvas_ids) + _snapshot_queues[sender_id] = queued func _apply_canvas_state(data: Dictionary) -> void: @@ -2085,12 +2104,21 @@ func _apply_canvas_state(data: Dictionary) -> void: and int(data["revision"]) <= int(previous_state["revision"]) ): return + var buffer := SurfaceDrawingCellBuffer.new() + if not buffer.configure_from_state(data, true): + return var normalized_state: Dictionary = data.duplicate(true) - normalized_state["participant_fingerprints"] = _state_participants( - normalized_state - ) + normalized_state.erase("cells") + normalized_state.erase("cell_encoding") + normalized_state.erase("cell_colors") + normalized_state.erase("cell_authors") + normalized_state["participant_fingerprints"] = buffer.get_participants() _canvas_states[canvas_id] = normalized_state - _rebuild_canvas_node(normalized_state) + _canvas_cells[canvas_id] = buffer + if _should_load_canvas_node(normalized_state, FINALIZED_NODE_LOAD_DISTANCE): + _rebuild_canvas_node(normalized_state, buffer) + else: + _unload_canvas_node(canvas_id) _refresh_stencil_visibility() _emit_artwork_changed() if ( @@ -2105,7 +2133,10 @@ func _apply_canvas_state(data: Dictionary) -> void: ) -func _rebuild_canvas_node(data: Dictionary) -> void: +func _rebuild_canvas_node( + data: Dictionary, + buffer: SurfaceDrawingCellBuffer, +) -> void: var canvas_id: String = str(data["canvas_id"]) var previous_canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) if previous_canvas != null: @@ -2113,12 +2144,82 @@ func _rebuild_canvas_node(data: Dictionary) -> void: var canvas := SurfaceDrawingCanvas.new() canvas.name = "Drawing_%s" % canvas_id _drawing_root.add_child(canvas) - if not canvas.setup(data, _relationships, SOLID_SURFACE_MASK): + if not canvas.setup(data, _relationships, SOLID_SURFACE_MASK, buffer): canvas.queue_free() return _canvas_nodes[canvas_id] = canvas +func _refresh_finalized_canvas_streaming() -> void: + var now: int = Time.get_ticks_msec() + if now - _last_finalized_stream_msec < FINALIZED_STREAM_INTERVAL_MSEC: + return + _last_finalized_stream_msec = now + for canvas_id: String in _canvas_nodes.keys(): + var state: Dictionary = _canvas_states.get(canvas_id, {}) + if ( + bool(state.get("finalized", false)) + and not _should_load_canvas_node( + state, FINALIZED_NODE_UNLOAD_DISTANCE + ) + ): + _unload_canvas_node(canvas_id) + var candidates: Array[String] = [] + for canvas_id: String in _canvas_states: + var state: Dictionary = _canvas_states[canvas_id] + if ( + not _canvas_nodes.has(canvas_id) + and _should_load_canvas_node(state, FINALIZED_NODE_LOAD_DISTANCE) + ): + candidates.append(canvas_id) + candidates.sort_custom(func(first: String, second: String) -> bool: + return _canvas_distance_squared(first) < _canvas_distance_squared(second) + ) + for index: int in mini(candidates.size(), FINALIZED_NODE_LOADS_PER_PASS): + var canvas_id: String = candidates[index] + var buffer: SurfaceDrawingCellBuffer = _canvas_cells.get(canvas_id) + if buffer != null: + _rebuild_canvas_node(_canvas_states[canvas_id], buffer) + + +func _should_load_canvas_node(state: Dictionary, distance: float) -> bool: + if state.is_empty(): + return false + if not bool(state.get("finalized", false)): + return true + if _local_player == null or not is_instance_valid(_local_player): + return false + return _local_player.global_position.distance_squared_to( + SurfaceDrawingProtocol.array_to_vector(state["origin"]) + ) <= distance * distance + + +func _canvas_distance_squared(canvas_id: String) -> float: + var state: Dictionary = _canvas_states.get(canvas_id, {}) + if ( + state.is_empty() + or _local_player == null + or not is_instance_valid(_local_player) + ): + return INF + return _local_player.global_position.distance_squared_to( + SurfaceDrawingProtocol.array_to_vector(state["origin"]) + ) + + +func _unload_canvas_node(canvas_id: String) -> void: + var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) + if canvas != null: + canvas.queue_free() + _canvas_nodes.erase(canvas_id) + if _selected_canvas_id == canvas_id: + _selected_canvas_id = "" + if _hovered_canvas_id == canvas_id: + _hovered_canvas_id = "" + if _last_hovered_canvas_id == canvas_id: + _last_hovered_canvas_id = "" + + func _apply_canvas_update(data: Dictionary) -> void: if ( not SurfaceDrawingProtocol.validate_canvas_update(data) @@ -2127,12 +2228,14 @@ func _apply_canvas_update(data: Dictionary) -> void: return var canvas_id: String = str(data["canvas_id"]) var state: Dictionary = _canvas_states.get(canvas_id, {}) + var buffer: SurfaceDrawingCellBuffer = _canvas_cells.get(canvas_id) var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) - if state.is_empty() or canvas == null or int(data["revision"]) <= int(state["revision"]): + if ( + state.is_empty() + or buffer == null + or int(data["revision"]) <= int(state["revision"]) + ): return - var cells: Dictionary[int, Dictionary] = _cells_by_key( - state["cells"], int(state["width"]) - ) var grid_width: int = int(state["width"]) var grid_height: int = int(state["height"]) for edit_value: Variant in data["edits"]: @@ -2141,18 +2244,13 @@ func _apply_canvas_update(data: Dictionary) -> void: var y: int = int(edit["y"]) if x < 0 or x >= grid_width or y < 0 or y >= grid_height: return - var key: int = y * grid_width + x - if str(edit["color_id"]).is_empty(): - cells.erase(key) - else: - cells[key] = edit.duplicate(true) + if not buffer.apply_edits(data["edits"]): + return state["revision"] = int(data["revision"]) - state["cells"] = _sorted_cells(cells) - state["participant_fingerprints"] = _merged_participants( - state, data - ) + state["participant_fingerprints"] = buffer.get_participants() _canvas_states[canvas_id] = state - canvas.apply_update(data) + if canvas != null: + canvas.apply_buffered_update(data) _emit_artwork_changed() @@ -2167,7 +2265,6 @@ func _apply_guide_update(data: Dictionary) -> void: var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) if ( state.is_empty() - or canvas == null or int(data["revision"]) <= int(state["revision"]) ): return @@ -2179,8 +2276,13 @@ func _apply_guide_update(data: Dictionary) -> void: state["participant_fingerprints"] = _merged_participants( state, data ) + var buffer: SurfaceDrawingCellBuffer = _canvas_cells.get(canvas_id) + if buffer != null: + buffer.ensure_participants(state["participant_fingerprints"]) + state["participant_fingerprints"] = buffer.get_participants() _canvas_states[canvas_id] = state - canvas.apply_guide_update(data) + if canvas != null: + canvas.apply_guide_update(data) if _active: _emit_hud_state( "grid finalized • new grids may overlap its artwork" @@ -2193,13 +2295,90 @@ func _apply_guide_update(data: Dictionary) -> void: func _broadcast_canvas_state(data: Dictionary) -> void: + var network_state: Dictionary = _network_canvas_state( + str(data.get("canvas_id", "")) + ) + if network_state.is_empty(): + return for peer_id: int in _session.get_authenticated_peer_ids(): if peer_id == _session.get_local_peer_id(): continue if _session.peer_supports_capability( peer_id, SurfaceDrawingProtocol.CAPABILITY ): - receive_canvas_state.rpc_id(peer_id, data) + receive_canvas_state.rpc_id(peer_id, network_state) + + +func _network_canvas_state(canvas_id: String) -> Dictionary: + var state: Dictionary = _canvas_states.get(canvas_id, {}) + var buffer: SurfaceDrawingCellBuffer = _canvas_cells.get(canvas_id) + if state.is_empty() or buffer == null: + return {} + var result: Dictionary = state.duplicate(true) + result.merge(buffer.to_compact_fields(), true) + return result + + +func _pump_snapshot_queues() -> void: + if _session == null or not _session.is_host() or _snapshot_queues.is_empty(): + return + var peer_ids: Array[int] = [] + peer_ids.assign(_snapshot_queues.keys()) + peer_ids.sort() + for peer_id: int in peer_ids: + if not _session.is_authenticated_peer(peer_id): + _snapshot_queues.erase(peer_id) + continue + var queue: Array = _snapshot_queues.get(peer_id, []) + var sent_count: int = 0 + var sent_grid_cells: int = 0 + while not queue.is_empty() and sent_count < SNAPSHOT_CANVASES_PER_FRAME: + var canvas_id: String = str(queue.front()) + var state: Dictionary = _canvas_states.get(canvas_id, {}) + if state.is_empty(): + queue.pop_front() + continue + var grid_cells: int = int(state["width"]) * int(state["height"]) + if ( + sent_count > 0 + and sent_grid_cells + grid_cells > SNAPSHOT_GRID_CELLS_PER_FRAME + ): + break + queue.pop_front() + var network_state: Dictionary = _network_canvas_state(canvas_id) + if network_state.is_empty(): + continue + receive_canvas_state.rpc_id(peer_id, network_state) + sent_count += 1 + sent_grid_cells += grid_cells + if queue.is_empty(): + _snapshot_queues.erase(peer_id) + else: + _snapshot_queues[peer_id] = queue + + +func _snapshot_canvas_precedes( + first_id: String, + second_id: String, + peer_id: int, +) -> bool: + var first: Dictionary = _canvas_states.get(first_id, {}) + var second: Dictionary = _canvas_states.get(second_id, {}) + var first_finalized: bool = bool(first.get("finalized", false)) + var second_finalized: bool = bool(second.get("finalized", false)) + if first_finalized != second_finalized: + return not first_finalized + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar != null: + var first_distance: float = avatar.global_position.distance_squared_to( + SurfaceDrawingProtocol.array_to_vector(first.get("origin", [])) + ) + var second_distance: float = avatar.global_position.distance_squared_to( + SurfaceDrawingProtocol.array_to_vector(second.get("origin", [])) + ) + if not is_equal_approx(first_distance, second_distance): + return first_distance < second_distance + return int(first.get("layer", 0)) < int(second.get("layer", 0)) func _broadcast_canvas_update(data: Dictionary) -> void: @@ -2285,6 +2464,61 @@ func _state_cell_position(state: Dictionary, x: int, y: int) -> Vector3: ) +func _state_contains_world_point( + state: Dictionary, + world_point: Vector3, + tolerance: float = 0.3, +) -> bool: + var origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(state["origin"]) + var normal: Vector3 = SurfaceDrawingProtocol.array_to_vector( + state["normal"] + ).normalized() + var relative: Vector3 = world_point - origin + if absf(relative.dot(normal)) > tolerance: + return false + var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector( + state["tangent"] + ).normalized() + var bitangent: Vector3 = normal.cross(tangent).normalized() + var half_width: float = ( + float(state["width"]) * float(state["cell_size"]) * 0.5 + ) + var half_height: float = ( + float(state["height"]) * float(state["cell_size"]) * 0.5 + ) + var horizontal: float = relative.dot(tangent) + var vertical: float = relative.dot(bitangent) + return ( + horizontal >= -half_width + and horizontal < half_width + and vertical >= -half_height + and vertical < half_height + ) + + +func _state_cell_at_world_point( + state: Dictionary, + world_point: Vector3, +) -> Vector2i: + var origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(state["origin"]) + var normal: Vector3 = SurfaceDrawingProtocol.array_to_vector( + state["normal"] + ).normalized() + var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector( + state["tangent"] + ).normalized() + var bitangent: Vector3 = normal.cross(tangent).normalized() + var relative: Vector3 = world_point - origin + var size: float = float(state["cell_size"]) + var half_width: float = float(state["width"]) * size * 0.5 + var half_height: float = float(state["height"]) * size * 0.5 + var x: int = floori((relative.dot(tangent) + half_width) / size) + var y: int = floori((relative.dot(bitangent) + half_height) / size) + if x < 0 or x >= int(state["width"]) or y < 0 or y >= int(state["height"]): + return Vector2i(-1, -1) + return Vector2i(x, y) + + func _overlaps_existing_canvas( origin: Vector3, normal: Vector3, @@ -2335,10 +2569,27 @@ func _active_canvas_count() -> int: return count -func _allocated_grid_cells() -> int: +func _finalized_canvas_count() -> int: var count: int = 0 for state: Dictionary in _canvas_states.values(): - count += int(state.get("width", 0)) * int(state.get("height", 0)) + if bool(state.get("finalized", false)): + count += 1 + return count + + +func _allocated_active_grid_cells() -> int: + var count: int = 0 + for state: Dictionary in _canvas_states.values(): + if not bool(state.get("finalized", false)): + count += int(state.get("width", 0)) * int(state.get("height", 0)) + return count + + +func _allocated_finalized_grid_cells() -> int: + var count: int = 0 + for state: Dictionary in _canvas_states.values(): + if bool(state.get("finalized", false)): + count += int(state.get("width", 0)) * int(state.get("height", 0)) return count @@ -2678,30 +2929,10 @@ func _new_request_id(prefix: String) -> String: return "%s-%d-%d" % [prefix, Time.get_ticks_msec(), _request_sequence] -func _cells_by_key( - values: Array, - grid_width: int, -) -> Dictionary[int, Dictionary]: - var result: Dictionary[int, Dictionary] = {} - for value: Variant in values: - if typeof(value) != TYPE_DICTIONARY: - continue - var cell: Dictionary = value - var key: int = ( - int(cell.get("y", -1)) * grid_width - + int(cell.get("x", -1)) - ) - result[key] = cell.duplicate(true) - return result - - func _state_cell(state: Dictionary, x: int, y: int) -> Dictionary: - var cells: Dictionary[int, Dictionary] = _cells_by_key( - state.get("cells", []), int(state["width"]) - ) - return Dictionary( - cells.get(_cell_key_for_state(state, x, y), {}) - ).duplicate(true) + var canvas_id: String = str(state.get("canvas_id", "")) + var buffer: SurfaceDrawingCellBuffer = _canvas_cells.get(canvas_id) + return buffer.get_cell_value(x, y) if buffer != null else {} func _register_canvas_participant( @@ -2721,6 +2952,9 @@ func _register_canvas_participant( participants.append(fingerprint) state["participant_fingerprints"] = participants _canvas_states[canvas_id] = state + var buffer: SurfaceDrawingCellBuffer = _canvas_cells.get(canvas_id) + if buffer != null: + buffer.ensure_participants(participants) return true @@ -2740,17 +2974,17 @@ func _merged_participants( func _state_participants(state: Dictionary) -> Array[String]: + var buffer: SurfaceDrawingCellBuffer = _canvas_cells.get( + str(state.get("canvas_id", "")) + ) + if buffer != null: + return buffer.get_participants() var result: Array[String] = [] _append_participant( result, str(state.get("creator_fingerprint", "")) ) for value: Variant in state.get("participant_fingerprints", []): _append_participant(result, str(value)) - for cell_value: Variant in state.get("cells", []): - var cell: Dictionary = cell_value - _append_participant( - result, str(cell.get("author_fingerprint", "")) - ) return result @@ -2804,15 +3038,6 @@ func _mutation_key(canvas_id: String, x: int, y: int) -> String: return "%s:%d:%d" % [canvas_id, x, y] -func _sorted_cells(values: Dictionary[int, Dictionary]) -> Array[Dictionary]: - var keys: Array[int] = values.keys() - keys.sort() - var result: Array[Dictionary] = [] - for key: int in keys: - result.append(values[key].duplicate(true)) - return result - - func _normalized_or(value: Variant, fallback: Vector3) -> Vector3: var vector: Vector3 = value if typeof(value) == TYPE_VECTOR3 else fallback return fallback if vector.is_zero_approx() else vector.normalized() @@ -2850,6 +3075,7 @@ func _on_peer_removed(peer_id: int) -> void: _peer_request_ids.erase(peer_id) _stroke_history_by_peer.erase(peer_id) _peer_art_entitlements.erase(peer_id) + _snapshot_queues.erase(peer_id) func _on_local_art_entitlement_changed() -> void: @@ -2898,6 +3124,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: func _clear_session_artwork_state() -> void: _canvas_states.clear() + _canvas_cells.clear() for canvas: SurfaceDrawingCanvas in _canvas_nodes.values(): canvas.queue_free() _canvas_nodes.clear() @@ -2908,6 +3135,7 @@ func _clear_session_artwork_state() -> void: _stroke_history_by_peer.clear() _cell_last_stroke.clear() _last_local_stroke_id = "" + _snapshot_queues.clear() _hide_previews() _emit_artwork_changed() @@ -2932,16 +3160,8 @@ func _apply_artwork_fingerprint_clear(fingerprint: String) -> int: func _remove_canvas_state(canvas_id: String) -> void: _canvas_states.erase(canvas_id) - var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) - if canvas != null: - canvas.queue_free() - _canvas_nodes.erase(canvas_id) - if _selected_canvas_id == canvas_id: - _selected_canvas_id = "" - if _hovered_canvas_id == canvas_id: - _hovered_canvas_id = "" - if _last_hovered_canvas_id == canvas_id: - _last_hovered_canvas_id = "" + _canvas_cells.erase(canvas_id) + _unload_canvas_node(canvas_id) func _emit_artwork_changed() -> void: diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 84e862b..8bae718 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -46,6 +46,7 @@ readonly -a QUICK_TESTS=( "tests/player_experience_validation.gd" "tests/shoreline_ambience_validation.gd" "tests/surface_drawing_validation.gd" + "tests/surface_drawing_performance_validation.gd" "tests/surface_drawing_texture_renderer_validation.gd" "tests/tackle_order_validation.gd" "tests/terrain_biome_validation.gd" diff --git a/tests/surface_drawing_performance_validation.gd b/tests/surface_drawing_performance_validation.gd new file mode 100644 index 0000000..26158df --- /dev/null +++ b/tests/surface_drawing_performance_validation.gd @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +extends SceneTree + +const CANVAS_COUNT: int = 10 +const GRID_SIZE: int = 128 +const MAX_SETUP_MSEC: float = 2500.0 +const MAX_STATIC_MEMORY_MIB: float = 32.0 +const FINGERPRINT_A := ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) +const FINGERPRINT_B := ( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_finalized_capacity() + _validate_dense_cells_and_author_index() + var drawing_root := Node3D.new() + root.add_child(drawing_root) + var colors := PackedByteArray() + colors.resize(GRID_SIZE * GRID_SIZE) + colors.fill(1) + var authors := PackedInt32Array() + authors.resize(GRID_SIZE * GRID_SIZE) + authors.fill(1) + var memory_before: int = OS.get_static_memory_usage() + var started_usec: int = Time.get_ticks_usec() + for canvas_index: int in CANVAS_COUNT: + var canvas := SurfaceDrawingCanvas.new() + drawing_root.add_child(canvas) + var state: Dictionary = _compact_state( + "canvas-%d" % canvas_index, + canvas_index, + colors, + authors, + ) + assert(SurfaceDrawingProtocol.validate_canvas_state(state)) + assert(canvas.setup(state, null)) + assert(canvas.get_rendered_pixel_count() == GRID_SIZE * GRID_SIZE) + var artwork := canvas.get("_pixel_instance") as MeshInstance3D + var arrays: Array = artwork.mesh.surface_get_arrays(0) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + assert( + vertices.size() + <= (SurfaceDrawingCanvas.FINALIZED_MAX_SUBDIVISIONS + 1) ** 2 + ) + var setup_msec: float = ( + float(Time.get_ticks_usec() - started_usec) / 1000.0 + ) + var memory_mib: float = ( + float(OS.get_static_memory_usage() - memory_before) / 1048576.0 + ) + assert( + setup_msec <= MAX_SETUP_MSEC, + "finalized artwork setup took %.2f ms" % setup_msec, + ) + assert( + memory_mib <= MAX_STATIC_MEMORY_MIB, + "finalized artwork retained %.2f MiB" % memory_mib, + ) + print( + "Surface drawing performance validation: PASS (%.2f ms, %.2f MiB)" + % [setup_msec, memory_mib] + ) + drawing_root.queue_free() + await process_frame + quit() + + +func _validate_finalized_capacity() -> void: + var world_scene := load( + "res://world/generation/generated_world_region.tscn" + ) as PackedScene + assert(world_scene != null) + var world_region: Node = world_scene.instantiate() + var generator := world_region.get_node( + "Terrain/TerrainChunkGenerator" + ) as TerrainChunkGenerator + assert(generator != null and generator.catalog != null) + var canvas_extent: float = ( + float(SurfaceDrawingProtocol.MAX_GRID_SIZE) + * SurfaceDrawingProtocol.CELL_SIZE + ) + var required_columns: int = ceili( + float(generator.grid_size.x) * generator.catalog.chunk_size + / canvas_extent + ) + var required_rows: int = ceili( + float(generator.grid_size.y) * generator.catalog.chunk_size + / canvas_extent + ) + var required_canvases: int = required_columns * required_rows + var cells_per_canvas: int = ( + SurfaceDrawingProtocol.MAX_GRID_SIZE + * SurfaceDrawingProtocol.MAX_GRID_SIZE + ) + assert( + SurfaceDrawingProtocol.MAX_FINALIZED_CANVASES >= required_canvases, + "finalized canvas budget cannot tile the generated world", + ) + assert( + SurfaceDrawingProtocol.MAX_FINALIZED_GRID_CELLS + >= required_canvases * cells_per_canvas, + "finalized cell budget cannot tile the generated world", + ) + world_region.free() + + +func _validate_dense_cells_and_author_index() -> void: + var state: Dictionary = { + "session_id": "dense-cell-test", + "canvas_id": "dense-cell-canvas", + "origin": [0.0, 0.0, 0.0], + "normal": [0.0, 1.0, 0.0], + "tangent": [1.0, 0.0, 0.0], + "width": 16, + "height": 16, + "cell_size": SurfaceDrawingProtocol.CELL_SIZE, + "revision": 1, + "guide_visible": false, + "finalized": true, + "layer": 1, + "creator_fingerprint": FINGERPRINT_A, + "participant_fingerprints": [FINGERPRINT_A, FINGERPRINT_B], + "cells": [ + {"x": 1, "y": 2, "color_id": "coral", "author_fingerprint": FINGERPRINT_A}, + {"x": 3, "y": 4, "color_id": "blue", "author_fingerprint": FINGERPRINT_B}, + ], + } + var buffer := SurfaceDrawingCellBuffer.new() + assert(buffer.configure_from_state(state)) + assert(buffer.get_painted_count() == 2) + assert(buffer.get_author_cell_keys(FINGERPRINT_A) == PackedInt32Array([33])) + assert(buffer.get_author_cell_keys(FINGERPRINT_B) == PackedInt32Array([67])) + var compact: Dictionary = state.duplicate(true) + compact.erase("cells") + compact.merge(buffer.to_compact_fields(), true) + assert(SurfaceDrawingProtocol.validate_canvas_state(compact)) + var restored := SurfaceDrawingCellBuffer.new() + assert(restored.configure_from_state(compact)) + assert(restored.to_cells() == buffer.to_cells()) + assert(restored.apply_edits([ + {"x": 1, "y": 2, "color_id": "", "author_fingerprint": ""}, + ])) + assert(restored.get_author_cell_keys(FINGERPRINT_A).is_empty()) + assert(restored.get_painted_count() == 1) + + +func _compact_state( + canvas_id: String, + layer: int, + colors: PackedByteArray, + authors: PackedInt32Array, +) -> Dictionary: + return { + "session_id": "performance-test", + "canvas_id": canvas_id, + "origin": [float(layer) * 12.0, 0.0, 0.0], + "normal": [0.0, 1.0, 0.0], + "tangent": [1.0, 0.0, 0.0], + "width": GRID_SIZE, + "height": GRID_SIZE, + "cell_size": SurfaceDrawingProtocol.CELL_SIZE, + "revision": 1, + "guide_visible": false, + "finalized": true, + "layer": layer, + "creator_fingerprint": FINGERPRINT_A, + "participant_fingerprints": [FINGERPRINT_A], + "cell_encoding": SurfaceDrawingProtocol.CELL_ENCODING, + "cell_colors": colors, + "cell_authors": authors, + } diff --git a/tests/surface_drawing_performance_validation.gd.uid b/tests/surface_drawing_performance_validation.gd.uid new file mode 100644 index 0000000..85392a7 --- /dev/null +++ b/tests/surface_drawing_performance_validation.gd.uid @@ -0,0 +1 @@ +uid://g73q2xuy33nm diff --git a/tests/surface_drawing_texture_renderer_validation.gd b/tests/surface_drawing_texture_renderer_validation.gd index ed99383..e4bf7f7 100644 --- a/tests/surface_drawing_texture_renderer_validation.gd +++ b/tests/surface_drawing_texture_renderer_validation.gd @@ -94,6 +94,16 @@ func _run() -> void: if child is MeshInstance3D: artwork_mesh_count += 1 assert(artwork_mesh_count == 1) + var finalized_instance := canvas.get( + "_pixel_instance" + ) as MeshInstance3D + var finalized_material := finalized_instance.mesh.surface_get_material( + 0 + ) as StandardMaterial3D + assert( + finalized_material.albedo_texture + in persistent_textures + ) assert(canvas.get_export_image().get_size() == Vector2i(32, 32)) world.queue_free() await process_frame diff --git a/tests/surface_drawing_validation.gd b/tests/surface_drawing_validation.gd index 50ec1ba..d57b95a 100644 --- a/tests/surface_drawing_validation.gd +++ b/tests/surface_drawing_validation.gd @@ -44,9 +44,24 @@ func _validate_protocol_bounds() -> void: var grid_height: int = SurfaceDrawingProtocol.GRID_HEIGHT var grid_sizes: Variant = SurfaceDrawingProtocol.GRID_SIZES var cell_size: float = SurfaceDrawingProtocol.CELL_SIZE + assert( + str(SurfaceDrawingProtocol.CAPABILITY) + == NetworkProtocol.SURFACE_DRAWING_CAPABILITY + ) assert(grid_width == 16) assert(grid_height == 16) assert(grid_sizes == [16, 32, 64, 128]) + assert( + SurfaceDrawingProtocol.MAX_CANVASES + == SurfaceDrawingProtocol.MAX_ACTIVE_CANVASES + + SurfaceDrawingProtocol.MAX_FINALIZED_CANVASES + ) + assert( + SurfaceDrawingProtocol.MAX_FINALIZED_GRID_CELLS + >= SurfaceDrawingProtocol.MAX_FINALIZED_CANVASES + * SurfaceDrawingProtocol.MAX_GRID_SIZE + * SurfaceDrawingProtocol.MAX_GRID_SIZE + ) assert(is_equal_approx(cell_size, 0.075)) assert(is_equal_approx( float(grid_width) * cell_size, @@ -242,8 +257,12 @@ func _validate_canvas_geometry_and_collaboration() -> void: assert(not canvas.is_guide_visible()) assert(canvas.is_finalized()) assert(not canvas.has_guide_geometry()) - assert(pixel_instance.mesh == persistent_mesh) - assert(pixel_material.albedo_texture == persistent_texture) + var finalized_instance := canvas.get("_pixel_instance") as MeshInstance3D + var finalized_material := finalized_instance.mesh.surface_get_material( + 0 + ) as StandardMaterial3D + assert(finalized_instance.mesh != persistent_mesh) + assert(finalized_material.albedo_texture == persistent_texture) await process_frame var finished_pixel_scale: float = canvas.get_rendered_pixel_size() assert( @@ -265,8 +284,8 @@ func _validate_canvas_geometry_and_collaboration() -> void: }], } assert(canvas.apply_update(second_update)) - assert(pixel_instance.mesh == persistent_mesh) - assert(pixel_material.albedo_texture == persistent_texture) + assert(finalized_instance == canvas.get("_pixel_instance")) + assert(finalized_material.albedo_texture == persistent_texture) var cells: Array[Dictionary] = canvas.get_authoritative_cells() assert(cells.size() == 1) assert(cells[0]["color_id"] == "blue")