extends SceneTree ## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收(FIRST-MAC-PLAYABLE §8.2)。 ## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 playable_live_test 承担。 ## ## 每个 race × CPU/GPU × wait/run/attack/damage/dead,在动作起点/25%/50%/75%/末段取样: ## - 报告 requested_state / resolved_motion / fallback_reason,实际播放路径必须等于解析结果; ## - 资源确无动作时按旧客户端规则(GetMotionKey 失败 -> 保留当前动作)单独判定, ## 只允许移动循环动作走这条回退,并写进 report.fallbacks; ## - 贴图实际加载、材质有 shader、包围盒有限; ## - 同一姿态 CPU/GPU 包围盒差 <= max(1cm, 高度0.5%); ## - 非 headless 时输出正面、侧面和每 race 动作 contact sheet,截图不能是纯背景。 ## env: MT_RENDER_OUTPUT 输出目录;MT_FOREST_RACES 逗号分隔的 race 子集(调试用)。 const MobView = preload("res://ui/mob_view.gd") const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315] const STATES := ["wait", "run", "attack", "damage", "dead"] ## 旧客户端 CActorInstance::Move -> SetLoopMotion(RUN/WALK):缺动作时直接返回保留当前循环。 const MOVE_STATES := ["run"] const SAMPLES := [0.0, 0.25, 0.5, 0.75, 1.0] const SIZE := 640 const THUMB := 128 const BACKGROUND := Color(0.08, 0.1, 0.14) const TOLERANCE_FLOOR_M := 0.01 const TOLERANCE_HEIGHT_RATIO := 0.005 const MIN_FOREGROUND_RATIO := 0.002 var _output := "" var _failures: Array[String] = [] var _fallbacks: Array[Dictionary] = [] var _pose_bounds := {} var _results: Array[Dictionary] = [] var _races: Array = RACES func _initialize() -> void: call_deferred("run") func _fail(message: String) -> void: _failures.append(message) printerr("FOREST MOB FAIL: " + message) func run() -> void: _output = OS.get_environment("MT_RENDER_OUTPUT") if _output.is_empty(): _output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system()) DirAccess.make_dir_recursive_absolute(_output) var subset := OS.get_environment("MT_FOREST_RACES").strip_edges() if not subset.is_empty(): _races = [] for part in subset.split(",", false): _races.append(int(part)) root.size = Vector2i(SIZE, SIZE) _build_lighting() var cam := Camera3D.new() cam.fov = 38.0 cam.near = 0.02 root.add_child(cam) cam.make_current() for gpu in ["0", "1"]: OS.set_environment("MTGODOT_GPUSKIN", gpu) for race in _races: await _check_race(int(race), gpu, cam) OS.set_environment("MTGODOT_GPUSKIN", "0") var headless := DisplayServer.get_name() == "headless" var report := {"passed": _failures.is_empty(), "failures": _failures, "races": _races, "states": STATES, "gpu_modes": ["0", "1"], "sample_fractions": SAMPLES, "tolerance_rule": "max(%.2fm, %.1f%% of posed height)" % [TOLERANCE_FLOOR_M, TOLERANCE_HEIGHT_RATIO * 100.0], "fallback_rule": "reference CRaceData::GetMotionKey miss -> SetLoopMotion returns and the current loop keeps playing", "fallbacks": _fallbacks, "results": _results, "assets": AssetRoot.path(), "viewport": str(root.size), "headless": headless, "screenshots": not headless, "manual_review": "contact sheets and front/side views need one human sign-off; AABB bottom is not foot contact", "scope": "offline tree-monster assembly; no server spawn or 40250 pixel parity"} var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE) if file: file.store_string(JSON.stringify(report, "\t")) file.close() else: _fail("cannot write report.json") print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(), "fallbacks": _fallbacks.size(), "results": _results.size(), "output": _output})) # Batch marker (rendering_batch_test.sh); the JSON line above keeps the details. print("PASS: forest_mob_render_test (manual sign-off pending)" if _failures.is_empty() else "FAIL: forest_mob_render_test") quit(0 if _failures.is_empty() else 1) func _check_race(race: int, gpu: String, cam: Camera3D) -> void: var tag := "race=%d GPU=%s" % [race, gpu] var view := MobView.new() view.name = "ForestMob_%d_%s" % [race, gpu] root.add_child(view) if not view.build(AssetRoot.path(), null, race): _fail("%s build returned false" % tag) view.queue_free() await process_frame return # 交叉淡入只在 _process 里推进;取样要看动作本身的姿态,不看上一动作的残留混合。 if view.anim: view.anim.set("blend_time", 0.0) var row := {"race": race, "gpu_skin": gpu == "1", "directory": view._dir, "materials": _material_report(view.model, tag), "states": {}, "views": {}, "contact_sheet": ""} var shoot := DisplayServer.get_name() != "headless" var sheet: Image = null if shoot: sheet = Image.create(THUMB * SAMPLES.size(), THUMB * STATES.size(), false, Image.FORMAT_RGBA8) sheet.fill(BACKGROUND) var frame := {} for state_index in STATES.size(): var state: String = STATES[state_index] var resolution: Dictionary = view.resolve_motion(state) var before := String(view.anim.get("anim_path")) if view.anim else "" view.set_anim_state(state) if view.anim == null: _fail("%s state=%s has no animation player" % [tag, state]) continue var entry := {"requested_state": state, "resolved_motion": String(resolution.motion), "motion_path": String(resolution.path), "fallback_reason": String(resolution.fallback_reason), "status": "PASS", "samples": []} row.states[state] = entry var actual := String(view.anim.get("anim_path")) if String(resolution.path).is_empty(): if state in MOVE_STATES and resolution.fallback_reason == "reference_keep_current_motion" and actual == before and not before.is_empty(): entry.status = "REFERENCE_FALLBACK" entry["kept_motion_path"] = actual _fallbacks.append({"race": race, "gpu_skin": gpu == "1", "requested_state": state, "kept_motion_path": actual, "fallback_reason": resolution.fallback_reason, "needs_live_confirmation": "server movement of this race shows the kept loop while its position changes"}) else: entry.status = "FAIL" _fail("%s state=%s has no motion (%s; still playing %s)" % [tag, state, resolution.fallback_reason, actual]) continue if not FileAccess.file_exists(String(resolution.path)): entry.status = "FAIL" _fail("%s state=%s resolved a missing file %s" % [tag, state, resolution.path]) continue if actual != String(resolution.path): entry.status = "FAIL" _fail("%s state=%s resolved %s but played %s" % [tag, state, resolution.path, actual]) continue if state == "dead" and bool(view.anim.get("loop")): _fail("%s dead motion is looping" % tag) view.anim.set_process(true) await process_frame view.anim.set_process(false) var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 0.0 entry["duration"] = duration if duration <= 0.0: entry.status = "FAIL" _fail("%s state=%s has zero duration" % [tag, state]) continue for sample_index in SAMPLES.size(): var fraction: float = SAMPLES[sample_index] # 末段取最后一帧之前,避免非循环动作 set_time(duration) 被播放器夹回/结束。 var at := clampf(duration * fraction, 0.0, maxf(0.0, duration - 1.0 / 60.0)) view.anim.call("set_time", at) await process_frame var sample := _sample_pose(view, race, gpu, state, fraction, tag) sample["time"] = at entry.samples.append(sample) if not bool(sample.valid): entry.status = "FAIL" if shoot: if frame.is_empty(): frame = _frame_for(view) var image := await _capture(cam, frame, Vector3(0.35, 0.22, 1.0)) if not _has_foreground(image): entry.status = "FAIL" _fail("%s state=%s sample=%.2f screenshot is empty" % [tag, state, fraction]) image.convert(Image.FORMAT_RGBA8) image.resize(THUMB, THUMB, Image.INTERPOLATE_BILINEAR) sheet.blit_rect(image, Rect2i(0, 0, THUMB, THUMB), Vector2i(sample_index * THUMB, state_index * THUMB)) if state == "wait" and is_equal_approx(fraction, 0.5): for view_name in ["front", "side"]: var direction := Vector3(0.0, 0.18, 1.0) if view_name == "front" else Vector3(1.0, 0.18, 0.0) var shot := await _capture(cam, frame, direction) var file_name := "mob-%d-%s-%s.png" % [race, view_name, "gpu" if gpu == "1" else "cpu"] if not _has_foreground(shot) or shot.save_png(_output.path_join(file_name)) != OK: _fail("%s %s view screenshot failed" % [tag, view_name]) row.views[view_name] = file_name if shoot: var sheet_name := "mob-%d-sheet-%s.png" % [race, "gpu" if gpu == "1" else "cpu"] if not _has_foreground(sheet) or sheet.save_png(_output.path_join(sheet_name)) != OK: _fail("%s contact sheet save failed" % tag) row.contact_sheet = sheet_name _results.append(row) view.queue_free() await process_frame ## 同一 race/state/fraction:CPU 先记录,GPU 比较。容差 max(1cm, 高度0.5%),米制世界空间。 func _sample_pose(view: Node3D, race: int, gpu: String, state: String, fraction: float, tag: String) -> Dictionary: var local: AABB = view.model.call("get_visual_aabb") var bounds: AABB = view.model.global_transform * local var out := {"fraction": fraction, "valid": true, "aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z], "aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z]} if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0: out.valid = false _fail("%s state=%s sample=%.2f has invalid visual AABB" % [tag, state, fraction]) return out var key := "%d/%s/%.2f" % [race, state, fraction] if gpu == "0": _pose_bounds[key] = bounds return out if not _pose_bounds.has(key): out.valid = false _fail("%s state=%s sample=%.2f has no CPU pose to compare" % [tag, state, fraction]) return out var cpu: AABB = _pose_bounds[key] var tolerance := maxf(TOLERANCE_FLOOR_M, maxf(cpu.size.y, bounds.size.y) * TOLERANCE_HEIGHT_RATIO) var delta := maxf(cpu.position.distance_to(bounds.position), cpu.end.distance_to(bounds.end)) out["cpu_gpu_delta_m"] = delta out["tolerance_m"] = tolerance if delta > tolerance: out.valid = false _fail("%s state=%s sample=%.2f CPU/GPU AABB differs %.4fm > %.4fm" % [tag, state, fraction, delta, tolerance]) return out ## 贴图实际绑定到材质、材质有 shader。双面/混合按资源记录,不做统一强制。 func _material_report(model: Node, tag: String) -> Dictionary: var surfaces := 0 var textured := 0 var two_sided := 0 var invalid := 0 for node in model.find_children("*", "MeshInstance3D", true, false): var instance := node as MeshInstance3D if instance.mesh == null: continue for surface in instance.mesh.get_surface_count(): surfaces += 1 var material := instance.get_active_material(surface) var shader_material := material as ShaderMaterial if shader_material != null: if shader_material.shader == null: invalid += 1 continue if bool(shader_material.get_shader_parameter("use_texture")) and shader_material.get_shader_parameter("albedo_tex") != null: textured += 1 if shader_material.shader.code.contains("cull_disabled"): two_sided += 1 elif material is BaseMaterial3D: if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null: textured += 1 if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED: two_sided += 1 else: invalid += 1 if surfaces == 0: _fail("%s model has no mesh surfaces" % tag) if invalid > 0: _fail("%s has %d surfaces without a valid material" % [tag, invalid]) if textured < surfaces: _fail("%s only %d/%d surfaces have a loaded texture" % [tag, textured, surfaces]) return {"surfaces": surfaces, "textured": textured, "two_sided": two_sided, "invalid": invalid} ## 按首个采样姿态固定机位,同一 race 的所有截图可直接对比。 func _frame_for(view: Node3D) -> Dictionary: var bounds: AABB = view.model.global_transform * (view.model.call("get_visual_aabb") as AABB) var radius := maxf(0.5, bounds.size.length() * 0.5) return {"centre": bounds.get_center(), "distance": maxf(3.0, radius * 3.6)} func _capture(cam: Camera3D, frame: Dictionary, direction: Vector3) -> Image: var centre: Vector3 = frame.centre cam.position = centre + direction.normalized() * float(frame.distance) cam.look_at(centre, Vector3.UP) await RenderingServer.frame_post_draw return root.get_texture().get_image() func _has_foreground(image: Image) -> bool: if image == null or image.is_empty(): return false var hits := 0 var step := 4 var total := 0 for y in range(0, image.get_height(), step): for x in range(0, image.get_width(), step): total += 1 var c := image.get_pixel(x, y) if absf(c.r - BACKGROUND.r) + absf(c.g - BACKGROUND.g) + absf(c.b - BACKGROUND.b) > 0.06: hits += 1 return total > 0 and float(hits) / float(total) >= MIN_FOREGROUND_RATIO func _build_lighting() -> void: var sun := DirectionalLight3D.new() sun.rotation_degrees = Vector3(-42, 155, 0) sun.light_energy = 1.1 root.add_child(sun) var env := WorldEnvironment.new() var settings := Environment.new() settings.background_mode = Environment.BG_COLOR settings.background_color = BACKGROUND settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR settings.ambient_light_color = Color(0.55, 0.58, 0.65) settings.ambient_light_energy = 0.8 env.environment = settings root.add_child(env)