diff --git a/imgui.ini b/imgui.ini index 854da81c..8d1592d0 100644 --- a/imgui.ini +++ b/imgui.ini @@ -84,12 +84,12 @@ Size=400,300 Collapsed=0 [Window][选择路径] -Pos=390,125 +Pos=625,258 Size=600,500 Collapsed=0 [Window][打开项目] -Pos=440,175 +Pos=675,308 Size=500,400 Collapsed=0 diff --git a/project/webgl_packager.py b/project/webgl_packager.py index df642c40..23fb71a7 100644 --- a/project/webgl_packager.py +++ b/project/webgl_packager.py @@ -142,6 +142,8 @@ class WebGLPackager: shutil.copy2(str(entry), str(vendor_dst / entry.name)) self._try_resolve_vendor_files(vendor_dst) + self._copy_optional_postprocess_vendor_files(vendor_dst) + self._rewrite_vendor_module_imports(vendor_dst) self._strip_vendor_source_mapping_urls(vendor_dst) # Placeholder marker warning @@ -204,6 +206,118 @@ class WebGLPackager: if found_source: shutil.copy2(str(found_source), str(dst_path)) + def _copy_optional_postprocess_vendor_files(self, vendor_dst: Path) -> None: + """Try to copy optional postprocessing modules used by viewer approximation.""" + lookup_roots = [ + Path("/usr/share/javascript/three"), + Path("/usr/share/nodejs/three"), + Path("/usr/lib/node_modules/three"), + Path.home() / ".local/lib/node_modules/three", + ] + + optional_targets = { + "EffectComposer.js": [ + "examples/jsm/postprocessing/EffectComposer.js", + ], + "RenderPass.js": [ + "examples/jsm/postprocessing/RenderPass.js", + ], + "UnrealBloomPass.js": [ + "examples/jsm/postprocessing/UnrealBloomPass.js", + ], + "ShaderPass.js": [ + "examples/jsm/postprocessing/ShaderPass.js", + ], + "OutputPass.js": [ + "examples/jsm/postprocessing/OutputPass.js", + ], + "CopyShader.js": [ + "examples/jsm/shaders/CopyShader.js", + ], + "LuminosityHighPassShader.js": [ + "examples/jsm/shaders/LuminosityHighPassShader.js", + ], + } + + for dst_name, rel_candidates in optional_targets.items(): + dst_path = vendor_dst / dst_name + if dst_path.exists(): + continue + + found_source = None + for root in lookup_roots: + if not root.exists(): + continue + for rel in rel_candidates: + candidate = root / rel + if candidate.exists() and candidate.is_file(): + found_source = candidate + break + if found_source: + break + + if found_source: + try: + shutil.copy2(str(found_source), str(dst_path)) + except Exception: + continue + + def _rewrite_vendor_module_imports(self, vendor_dst: Path) -> None: + """Rewrite bare/relative Three.js addon imports to local vendor paths.""" + js_files = [p for p in vendor_dst.glob("*.js") if p.is_file()] + if not js_files: + return + + available_names = {p.name for p in js_files} + import_re = re.compile( + r"""(?P\bfrom\s+)(?P['"])(?P[^'"]+)(?P=quote)""" + ) + + def rewrite_spec(spec: str) -> str: + raw = str(spec or "").strip() + if not raw: + return raw + + if raw in {"three", "three.module.js", "three.module.min.js"}: + return "./three.module.min.js" + + if raw.startswith("three/addons/") or raw.startswith("three/examples/jsm/"): + return "./" + raw.split("/")[-1] + + if raw.endswith(".js"): + basename = os.path.basename(raw) + if basename in available_names: + return "./" + basename + + return raw + + for js_file in js_files: + if js_file.name == "three.module.min.js": + continue + try: + content = js_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + + replaced = False + + def _replace(match): + nonlocal replaced + old_spec = match.group("spec") + new_spec = rewrite_spec(old_spec) + if new_spec == old_spec: + return match.group(0) + replaced = True + return f"{match.group('prefix')}{match.group('quote')}{new_spec}{match.group('quote')}" + + updated = import_re.sub(_replace, content) + if not replaced: + continue + try: + js_file.write_text(updated, encoding="utf-8") + except Exception: + continue + def _strip_vendor_source_mapping_urls(self, vendor_dst: Path) -> None: """Remove sourceMappingURL hints to avoid noisy 404s in offline preview.""" line_patterns = ( @@ -449,7 +563,7 @@ class WebGLPackager: except Exception: pass - return { + entry = { "include_default_ground": True, "ambient_light": { "color": ambient_color, @@ -461,6 +575,428 @@ class WebGLPackager: "direction": directional_dir, }, } + skybox_entry = self._extract_skybox_entry() + if skybox_entry: + entry["skybox"] = skybox_entry + render_pipeline = self._extract_render_pipeline_settings() + if render_pipeline: + entry["render_pipeline"] = render_pipeline + return entry + + def _extract_skybox_entry(self) -> Dict[str, Any]: + """Export skybox texture config for viewer background.""" + skybox_source = self._find_runtime_skybox_texture_source() + if not skybox_source: + skybox_source = self._find_fallback_skybox_source() + if not skybox_source: + return {} + + if not self._is_supported_skybox_image(skybox_source): + self.report["warnings"].append(f"天空盒格式暂不支持,已跳过: {skybox_source}") + return {} + + uri = self._copy_asset_to_textures(skybox_source) + if not uri: + self.report["warnings"].append(f"天空盒资源复制失败: {skybox_source}") + return {} + + projection = self._guess_skybox_projection(skybox_source) + lower_color = self._extract_skybox_lower_hemisphere_color(skybox_source) + return { + "enabled": True, + "type": projection, + "uri": uri, + "apply_environment": projection != "skydome", + "clip_lower_hemisphere": projection == "skydome", + "lower_hemisphere_color": lower_color, + "horizon_blend": 0.06, + "horizon_sample_v": 0.01, + "lower_tint_strength": 0.65, + } + + def _extract_skybox_lower_hemisphere_color(self, skybox_source: str) -> List[float]: + """Pick lower hemisphere fill color, prefer horizon-adjacent sky tone.""" + horizon_rgb = self._estimate_skybox_horizon_rgb(skybox_source) + if horizon_rgb: + return horizon_rgb + + clear_rgb = self._extract_world_clear_rgb() + if clear_rgb and max(clear_rgb) > 0.02: + return clear_rgb + + # Final fallback: dark bluish neutral. + return [0.08, 0.10, 0.13] + + def _extract_world_clear_rgb(self) -> List[float]: + """Read active clear/background color from world/showbase if available.""" + + def _to_rgb(value: Any) -> List[float]: + if value is None: + return [] + try: + if hasattr(value, "__len__") and len(value) >= 3: # type: ignore[arg-type] + r = float(value[0]) # type: ignore[index] + g = float(value[1]) # type: ignore[index] + b = float(value[2]) # type: ignore[index] + return [max(0.0, min(1.0, r)), max(0.0, min(1.0, g)), max(0.0, min(1.0, b))] + except Exception: + return [] + return [] + + world_getters = ("getBackgroundColor", "get_background_color") + for name in world_getters: + getter = getattr(self.world, name, None) + if callable(getter): + try: + rgb = _to_rgb(getter()) + except Exception: + rgb = [] + if rgb: + return rgb + + win = getattr(self.world, "win", None) + if win and hasattr(win, "getClearColor"): + try: + rgb = _to_rgb(win.getClearColor()) + except Exception: + rgb = [] + if rgb: + return rgb + + rp = getattr(self.world, "render_pipeline", None) + showbase = getattr(rp, "_showbase", None) if rp else None + rp_win = getattr(showbase, "win", None) if showbase else None + if rp_win and hasattr(rp_win, "getClearColor"): + try: + rgb = _to_rgb(rp_win.getClearColor()) + except Exception: + rgb = [] + if rgb: + return rgb + + return [] + + @staticmethod + def _estimate_skybox_horizon_rgb(path: str) -> List[float]: + """Estimate horizon color from skydome texture bottom band.""" + try: + from PIL import Image, ImageStat # type: ignore + except Exception: + return [] + + try: + with Image.open(path) as im: + rgb_img = im.convert("RGB") + width, height = rgb_img.size + if width <= 0 or height <= 0: + return [] + band_h = max(1, int(height // 64)) + band = rgb_img.crop((0, height - band_h, width, height)) + stat = ImageStat.Stat(band) + mean = stat.mean[:3] if stat.mean else [0.0, 0.0, 0.0] + return [ + max(0.0, min(1.0, float(mean[0]) / 255.0)), + max(0.0, min(1.0, float(mean[1]) / 255.0)), + max(0.0, min(1.0, float(mean[2]) / 255.0)), + ] + except Exception: + return [] + + def _find_runtime_skybox_texture_source(self) -> str: + candidates: List[str] = [] + skybox_np = getattr(self.world, "skybox", None) + if skybox_np and not self._is_np_empty(skybox_np): + candidates.extend(self._extract_texture_sources_from_node(skybox_np)) + + render = getattr(self.world, "render", None) + if render: + try: + skyboxes = render.findAllMatches("**/skybox*") + except Exception: + skyboxes = None + if skyboxes: + try: + count = skyboxes.getNumPaths() + except Exception: + count = 0 + for i in range(count): + try: + np = skyboxes.getPath(i) + except Exception: + continue + if self._is_np_empty(np): + continue + candidates.extend(self._extract_texture_sources_from_node(np)) + + for source in candidates: + if source and self._is_supported_skybox_image(source): + return source + return "" + + def _extract_texture_sources_from_node(self, node) -> List[str]: + out: List[str] = [] + for _, tex_hint in self._extract_texture_stage_and_paths(node): + resolved = self._resolve_skybox_path(tex_hint) + if resolved: + out.append(resolved) + return out + + def _resolve_skybox_path(self, path_hint: str) -> str: + text = str(path_hint or "").strip() + if not text: + return "" + + if os.path.isabs(text) and os.path.exists(text): + return os.path.normpath(text) + + repo_root = str(Path(__file__).resolve().parent.parent) + roots = [ + self._project_path, + os.path.join(self._project_path, "Resources"), + os.path.join(self._project_path, "scenes", "resources"), + repo_root, + os.path.join(repo_root, "RenderPipelineFile"), + os.path.join(repo_root, "RenderPipelineFile", "data"), + os.path.join(repo_root, "RenderPipelineFile", "data", "builtin_models", "skybox"), + os.getcwd(), + ] + for root in roots: + if not root: + continue + full = os.path.normpath(os.path.join(root, text)) + if os.path.exists(full): + return full + return "" + + def _find_fallback_skybox_source(self) -> str: + repo_root = str(Path(__file__).resolve().parent.parent) + candidates = [ + os.path.join(self._project_path, "Resources", "skybox.jpg"), + os.path.join(self._project_path, "Resources", "skybox.png"), + os.path.join(self._project_path, "skybox.jpg"), + os.path.join(self._project_path, "skybox.png"), + os.path.join(repo_root, "RenderPipelineFile", "data", "builtin_models", "skybox", "skybox.jpg"), + os.path.join(repo_root, "RenderPipelineFile", "data", "builtin_models", "skybox", "skybox-2.jpg"), + ] + for path in candidates: + if path and os.path.exists(path) and self._is_supported_skybox_image(path): + return os.path.normpath(path) + return "" + + @staticmethod + def _is_supported_skybox_image(path: str) -> bool: + ext = os.path.splitext(str(path or ""))[1].lower() + return ext in {".jpg", ".jpeg", ".png", ".webp", ".avif"} + + @staticmethod + def _guess_skybox_projection(path: str) -> str: + text = str(path or "").replace("\\", "/").lower() + if "/builtin_models/skybox/skybox" in text: + # RenderPipeline default assets are skydome textures. + return "skydome" + + width = 0 + height = 0 + try: + from PIL import Image # type: ignore + with Image.open(path) as im: + width, height = im.size + except Exception: + width, height = 0, 0 + + if width > 0 and height > 0: + ratio = float(width) / float(max(1, height)) + if 3.2 <= ratio <= 4.8: + return "skydome" + if 1.8 <= ratio <= 2.2: + return "equirectangular" + return "equirectangular" + + def _extract_render_pipeline_settings(self) -> Dict[str, Any]: + rp = getattr(self.world, "render_pipeline", None) + plugin_mgr = getattr(rp, "plugin_mgr", None) if rp else None + if not plugin_mgr: + return {} + + enabled_plugins = set(getattr(plugin_mgr, "enabled_plugins", set()) or set()) + + tone_operator = str( + self._get_rp_plugin_setting(plugin_mgr, "color_correction", "tonemap_operator", "optimized") + ).strip().lower() + if not tone_operator: + tone_operator = "optimized" + + exposure_scale = self._safe_float( + self._get_rp_plugin_setting(plugin_mgr, "color_correction", "exposure_scale", 1.0), + 1.0, + ) + min_exposure = self._safe_float( + self._get_rp_plugin_setting(plugin_mgr, "color_correction", "min_exposure_value", 0.01), + 0.01, + ) + max_exposure = self._safe_float( + self._get_rp_plugin_setting(plugin_mgr, "color_correction", "max_exposure_value", 1.0), + 1.0, + ) + if max_exposure < min_exposure: + min_exposure, max_exposure = max_exposure, min_exposure + + manual_camera = bool( + self._get_rp_plugin_setting(plugin_mgr, "color_correction", "manual_camera_parameters", False) + ) + web_exposure_boost = 1.0 + if tone_operator in {"optimized", "uncharted2"}: + web_exposure_boost = 1.6 + elif tone_operator == "reinhard": + web_exposure_boost = 1.25 + elif tone_operator in {"exponential", "exponential2"}: + web_exposure_boost = 1.15 + + bloom_enabled = "bloom" in enabled_plugins + bloom_strength = self._safe_float( + self._get_rp_plugin_setting(plugin_mgr, "bloom", "bloom_strength", 0.0), + 0.0, + ) + bloom_mips = int(self._safe_float( + self._get_rp_plugin_setting(plugin_mgr, "bloom", "num_mipmaps", 6), + 6.0, + )) + bloom_lens_dirt = self._safe_float( + self._get_rp_plugin_setting(plugin_mgr, "bloom", "lens_dirt_factor", 0.0), + 0.0, + ) + + pssm_enabled = "pssm" in enabled_plugins + shadow_resolution = int(self._safe_float( + self._get_rp_plugin_setting(plugin_mgr, "pssm", "resolution", 1024), + 1024.0, + )) + shadow_resolution = max(256, min(4096, shadow_resolution)) + shadow_max_distance = self._safe_float( + self._get_rp_plugin_setting(plugin_mgr, "pssm", "max_distance", 50.0), + 50.0, + ) + shadow_use_pcf = bool( + self._get_rp_plugin_setting(plugin_mgr, "pssm", "use_pcf", True) + ) + + ao_enabled = "ao" in enabled_plugins + ao_strength = self._safe_float( + self._get_rp_plugin_setting(plugin_mgr, "ao", "occlusion_strength", 1.0), + 1.0, + ) + ao_technique = str( + self._get_rp_plugin_setting(plugin_mgr, "ao", "technique", "SSAO") + ).strip().upper() or "SSAO" + + daytime_value = self._safe_float(getattr(getattr(rp, "daytime_mgr", None), "time", 12.0), 12.0) + fog_enabled = "volumetrics" in enabled_plugins + fog_ramp = self._safe_float( + self._get_rp_day_setting(plugin_mgr, rp, "volumetrics", "fog_ramp_size", 2000.0), + 2000.0, + ) + fog_intensity = self._safe_float( + self._get_rp_day_setting(plugin_mgr, rp, "volumetrics", "fog_intensity", 0.0), + 0.0, + ) + fog_color_raw = self._get_rp_day_setting(plugin_mgr, rp, "volumetrics", "fog_color", (140.0, 150.0, 165.0)) + fog_color = self._normalize_daytime_color(fog_color_raw) + + # Viewer side optional postprocessing modules. + has_bloom_vendor = all( + os.path.exists(os.path.join(self._output_root, "vendor", name)) + for name in ("EffectComposer.js", "RenderPass.js", "UnrealBloomPass.js") + ) + if bloom_enabled and not has_bloom_vendor: + self.report["warnings"].append( + "Bloom 已启用,但未找到 EffectComposer/RenderPass/UnrealBloomPass,本次 Web 导出将退化为无 Bloom。" + ) + + return { + "source": "render_pipeline", + "daytime": daytime_value, + "enabled_plugins": sorted(str(v) for v in enabled_plugins), + "tone_mapping": { + "operator": tone_operator, + "exposure_scale": exposure_scale, + "web_exposure_boost": web_exposure_boost, + "web_use_tone_mapping": False, + "manual_camera_parameters": manual_camera, + "min_exposure": min_exposure, + "max_exposure": max_exposure, + }, + "bloom": { + "enabled": bloom_enabled and bloom_strength > 1e-5, + "strength": max(0.0, bloom_strength), + "mipmaps": max(2, bloom_mips), + "lens_dirt_factor": max(0.0, min(1.0, bloom_lens_dirt)), + "vendor_available": has_bloom_vendor, + }, + "shadows": { + "enabled": pssm_enabled, + "resolution": shadow_resolution, + "max_distance": max(1.0, shadow_max_distance), + "use_pcf": shadow_use_pcf, + "web_enable": False, + }, + "ao": { + "enabled": ao_enabled, + "technique": ao_technique, + "occlusion_strength": max(0.0, ao_strength), + }, + "fog": { + "enabled": fog_enabled and fog_intensity > 1e-6, + "color": fog_color, + "intensity": max(0.0, fog_intensity), + "ramp_size": max(1.0, fog_ramp), + }, + "aa": { + "smaa_enabled": "smaa" in enabled_plugins, + "fxaa_enabled": "fxaa" in enabled_plugins, + }, + } + + @staticmethod + def _get_rp_plugin_setting(plugin_mgr, plugin_id: str, setting_id: str, default: Any) -> Any: + try: + plugin_settings = getattr(plugin_mgr, "settings", {}).get(plugin_id) + if plugin_settings and setting_id in plugin_settings: + handle = plugin_settings[setting_id] + return getattr(handle, "value", default) + except Exception: + pass + return default + + def _get_rp_day_setting(self, plugin_mgr, rp, plugin_id: str, setting_id: str, default: Any) -> Any: + try: + day_settings = getattr(plugin_mgr, "day_settings", {}).get(plugin_id) + if not day_settings or setting_id not in day_settings: + return default + handle = day_settings[setting_id] + if not hasattr(handle, "get_scaled_value_at"): + return default + daytime = self._safe_float(getattr(getattr(rp, "daytime_mgr", None), "time", 12.0), 12.0) + return handle.get_scaled_value_at(daytime) + except Exception: + return default + + @staticmethod + def _normalize_daytime_color(value: Any) -> List[float]: + if isinstance(value, (list, tuple)) and len(value) >= 3: + out = [ + float(value[0]), + float(value[1]), + float(value[2]), + ] + else: + out = [0.55, 0.6, 0.65] + if any(v > 1.0 for v in out): + out = [v / 255.0 for v in out] + return [ + max(0.0, min(1.0, out[0])), + max(0.0, min(1.0, out[1])), + max(0.0, min(1.0, out[2])), + ] def _build_model_node_entry(self, node) -> Optional[Dict[str, Any]]: node_id = self._node_id_by_pointer.get(id(node)) @@ -512,6 +1048,9 @@ class WebGLPackager: animation = self._extract_animation_settings(node, model_source) if animation: entry["animation"] = animation + scripts = self._extract_script_settings(node) + if scripts: + entry["scripts"] = scripts if textures: entry["texture_overrides"] = textures if subnode_overrides: @@ -567,6 +1106,276 @@ class WebGLPackager: "autoplay": mode != "stop", } + def _extract_script_settings(self, node) -> List[Dict[str, Any]]: + runtime_entries = self._extract_runtime_script_entries(node) + tag_entries = self._extract_tag_script_entries(node) + + if not runtime_entries and not tag_entries: + return [] + + merged_entries: List[Dict[str, Any]] = [] + merged_index: Dict[str, int] = {} + + for entry in runtime_entries: + name = str(entry.get("name", "")).strip() + key = self._normalize_script_name_key(name) + if not key: + continue + merged_index[key] = len(merged_entries) + merged_entries.append(dict(entry)) + + for entry in tag_entries: + name = str(entry.get("name", "")).strip() + key = self._normalize_script_name_key(name) + if not key: + continue + if key in merged_index: + runtime_entry = merged_entries[merged_index[key]] + if entry.get("file") and not runtime_entry.get("file"): + runtime_entry["file"] = entry.get("file") + continue + merged_index[key] = len(merged_entries) + merged_entries.append(dict(entry)) + + return merged_entries + + def _extract_runtime_script_entries(self, node) -> List[Dict[str, Any]]: + script_manager = getattr(self.world, "script_manager", None) + if not script_manager: + return [] + + get_scripts = getattr(script_manager, "get_scripts_on_object", None) + if not callable(get_scripts): + return [] + + try: + script_components = get_scripts(node) or [] + except Exception: + script_components = [] + + if not script_components: + return [] + + out: List[Dict[str, Any]] = [] + loader = getattr(script_manager, "loader", None) + find_script_file = getattr(loader, "find_script_file", None) if loader else None + + for component in script_components: + script_instance = getattr(component, "script_instance", None) + script_name = str(getattr(component, "script_name", "")).strip() + if not script_name and script_instance is not None: + script_name = script_instance.__class__.__name__ + script_name = str(script_name or "").strip() + if not script_name: + continue + + script_file = "" + if callable(find_script_file): + try: + script_file = str(find_script_file(script_name) or "").strip() + except Exception: + script_file = "" + + entry: Dict[str, Any] = { + "name": script_name, + "enabled": bool(getattr(component, "enabled", True)), + } + if script_file: + entry["file"] = script_file + + params = self._serialize_script_instance_params(script_instance) + if params: + entry["params"] = params + + out.append(entry) + + return out + + def _extract_tag_script_entries(self, node) -> List[Dict[str, Any]]: + has_scripts = self._safe_get_tag_value(node, "has_scripts").lower() == "true" + raw = self._safe_get_tag_value(node, "scripts_info") + if not has_scripts and not raw: + return [] + if not raw: + return [] + + try: + info_list = json.loads(raw) + except Exception: + self.report["warnings"].append( + f"scripts_info 解析失败,已忽略: {node.getName()}" + ) + return [] + + if not isinstance(info_list, list): + return [] + + out: List[Dict[str, Any]] = [] + for item in info_list: + if not isinstance(item, dict): + continue + script_name = str(item.get("name", "")).strip() + if not script_name: + continue + entry: Dict[str, Any] = { + "name": script_name, + "enabled": bool(item.get("enabled", True)), + } + script_file = str(item.get("file", "")).strip() + if script_file: + entry["file"] = script_file + params = item.get("params") + if isinstance(params, dict) and params: + entry["params"] = params + out.append(entry) + + return out + + @staticmethod + def _normalize_script_name_key(script_name: str) -> str: + normalized = str(script_name or "").strip().lower() + if normalized.endswith(".py"): + normalized = normalized[:-3] + normalized = re.sub(r"[^a-z0-9]+", "", normalized) + return normalized + + def _serialize_script_instance_params(self, script_instance) -> Dict[str, Any]: + if not script_instance: + return {} + + blocked_keys = { + "enabled", + "gameObject", + "transform", + "world", + } + out: Dict[str, Any] = {} + instance_vars = getattr(script_instance, "__dict__", {}) or {} + + for key, value in instance_vars.items(): + key_str = str(key).strip() + if not key_str: + continue + if key_str.startswith("_") or key_str in blocked_keys: + continue + serialized = self._serialize_script_value(value, depth=0) + if serialized is None and value is not None: + continue + out[key_str] = serialized + + return out + + def _serialize_script_value(self, value: Any, depth: int = 0) -> Any: + if depth > 4: + return None + if value is None: + return None + if isinstance(value, (bool, int, float, str)): + return value + + if self._is_nodepath_like(value): + ref = self._serialize_node_reference(value) + return {"__node_ref__": ref} if ref else None + + vec_value = self._serialize_vec_like(value) + if vec_value is not None: + return vec_value + + if isinstance(value, (list, tuple)): + out = [] + for item in value: + serialized = self._serialize_script_value(item, depth + 1) + if serialized is None and item is not None: + continue + out.append(serialized) + return out + + if isinstance(value, dict): + out = {} + for k, v in value.items(): + key = str(k) + if not key: + continue + serialized = self._serialize_script_value(v, depth + 1) + if serialized is None and v is not None: + continue + out[key] = serialized + return out + + return None + + @staticmethod + def _is_nodepath_like(value: Any) -> bool: + return ( + value is not None + and hasattr(value, "getName") + and hasattr(value, "getParent") + and hasattr(value, "isEmpty") + ) + + def _serialize_node_reference(self, node) -> Dict[str, Any]: + if self._is_np_empty(node): + return {} + + ref: Dict[str, Any] = {} + + node_id = self._node_id_by_pointer.get(id(node)) + if node_id: + ref["node_id"] = node_id + try: + node_name = str(node.getName() or "").strip() + except Exception: + node_name = "" + if node_name: + ref["node_name"] = node_name + + if node_id: + return ref + + # Fallback: track ancestor exported node + child name chain. + chain: List[str] = [] + current = node + for _ in range(64): + if self._is_np_empty(current): + break + current_id = self._node_id_by_pointer.get(id(current)) + if current_id: + ref["ancestor_node_id"] = current_id + chain.reverse() + if chain: + ref["child_name_chain"] = chain + break + try: + current_name = str(current.getName() or "").strip() + except Exception: + current_name = "" + if current_name: + chain.append(current_name) + try: + current = current.getParent() + except Exception: + break + + return ref + + @staticmethod + def _serialize_vec_like(value: Any) -> Optional[List[float]]: + get_components = [] + for attr in ("getX", "getY", "getZ", "getW"): + getter = getattr(value, attr, None) + if callable(getter): + get_components.append(getter) + if not get_components: + return None + + out: List[float] = [] + for getter in get_components: + try: + out.append(float(getter())) + except Exception: + return None + return out + def _collect_ssbo_subnode_transform_overrides(self, model_node) -> Dict[str, Any]: """ Collect changed subnode transforms from SSBO controller runtime state. diff --git a/templates/webgl/viewer.js b/templates/webgl/viewer.js index 7dcdf211..333e0be3 100644 --- a/templates/webgl/viewer.js +++ b/templates/webgl/viewer.js @@ -971,6 +971,245 @@ function directionToThree(THREE, direction, basis) { return d.normalize(); } +function applyShadowFlags(root, enabled) { + if (!root || !root.traverse) return; + root.traverse((obj) => { + if (obj.isMesh) { + obj.castShadow = !!enabled; + obj.receiveShadow = !!enabled; + } + }); +} + +function normalizeColorInput(color, fallback = [1, 1, 1]) { + const out = toColorArray(color, fallback); + if (out.some((v) => Number(v) > 1.0)) { + return out.map((v) => Number(v) / 255.0); + } + return out; +} + +function mapToneMappingOperator(THREE, operator) { + const op = String(operator || "").trim().toLowerCase(); + if (op === "none") return THREE.NoToneMapping; + if (op === "reinhard") return THREE.ReinhardToneMapping; + if (op === "exponential" || op === "exponential2") return THREE.CineonToneMapping; + // RenderPipeline's optimized/uncharted2 are filmic-like; ACES is the closest built-in curve. + return THREE.ACESFilmicToneMapping; +} + +function clampPositive(value, fallback) { + const v = toFiniteNumber(value, fallback); + return Number.isFinite(v) && v > 0 ? v : fallback; +} + +function applyRenderPipelineApproximation(THREE, renderer, scene, profile) { + const cfg = (profile && typeof profile === "object") ? profile : {}; + const tone = (cfg.tone_mapping && typeof cfg.tone_mapping === "object") ? cfg.tone_mapping : {}; + const shadows = (cfg.shadows && typeof cfg.shadows === "object") ? cfg.shadows : {}; + const fog = (cfg.fog && typeof cfg.fog === "object") ? cfg.fog : {}; + const bloom = (cfg.bloom && typeof cfg.bloom === "object") ? cfg.bloom : {}; + + // Keep lighting calibration closer to the editor defaults. + renderer.physicallyCorrectLights = false; + const useToneMapping = toBoolean(tone.web_use_tone_mapping, false); + if (useToneMapping) { + renderer.toneMapping = mapToneMappingOperator(THREE, tone.operator); + const webExposureBoost = Math.max(0.5, Math.min(3.0, toFiniteNumber(tone.web_exposure_boost, 1.0))); + renderer.toneMappingExposure = clampPositive(tone.exposure_scale, 1.0) * webExposureBoost; + } else { + renderer.toneMapping = THREE.NoToneMapping; + renderer.toneMappingExposure = 1.0; + } + + const shadowEnabled = toBoolean(shadows.enabled, false) && toBoolean(shadows.web_enable, false); + const shadowRes = Math.max(256, Math.min(4096, Math.round(clampPositive(shadows.resolution, 1024)))); + renderer.shadowMap.enabled = shadowEnabled; + renderer.shadowMap.type = toBoolean(shadows.use_pcf, true) ? THREE.PCFSoftShadowMap : THREE.PCFShadowMap; + + let fogApplied = false; + if (toBoolean(fog.enabled, false)) { + const fogColor = normalizeColorInput(fog.color, [0.55, 0.6, 0.7]); + const fogIntensity = Math.max(0.0, toFiniteNumber(fog.intensity, 0)); + const fogRamp = Math.max(1.0, toFiniteNumber(fog.ramp_size, 2000)); + const density = Math.max(0.00001, Math.min(0.2, (fogIntensity / fogRamp) * 0.2)); + scene.fog = new THREE.FogExp2(new THREE.Color(fogColor[0], fogColor[1], fogColor[2]), density); + fogApplied = true; + } else { + scene.fog = null; + } + + return { + shadowEnabled, + shadowResolution: shadowRes, + toneMappingEnabled: useToneMapping, + fogApplied, + bloom: { + enabled: toBoolean(bloom.enabled, false), + strength: Math.max(0.0, toFiniteNumber(bloom.strength, 0)), + vendorAvailable: toBoolean(bloom.vendor_available, false), + mipmaps: Math.max(2, Math.min(10, Math.round(clampPositive(bloom.mipmaps, 6)))), + }, + }; +} + +async function tryCreateBloomComposer(THREE, renderer, scene, camera, bloomConfig) { + const bloomEnabled = toBoolean(bloomConfig?.enabled, false); + const bloomStrength = Math.max(0.0, toFiniteNumber(bloomConfig?.strength, 0)); + const vendorAvailable = toBoolean(bloomConfig?.vendorAvailable, true); + if (!bloomEnabled || bloomStrength <= 1e-4) { + return { composer: null, enabled: false, reason: "disabled" }; + } + if (!vendorAvailable) { + return { composer: null, enabled: false, reason: "vendor_missing" }; + } + + try { + const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }] = await Promise.all([ + import("../vendor/EffectComposer.js"), + import("../vendor/RenderPass.js"), + import("../vendor/UnrealBloomPass.js"), + ]); + + const composer = new EffectComposer(renderer); + composer.addPass(new RenderPass(scene, camera)); + + const bloomPass = new UnrealBloomPass( + new THREE.Vector2(window.innerWidth, window.innerHeight), + bloomStrength, + 0.35, + 0.9, + ); + composer.addPass(bloomPass); + return { composer, enabled: true, reason: "ok" }; + } catch (err) { + console.warn("[WebGLPack] Bloom pass unavailable, fallback to base render:", err); + return { composer: null, enabled: false, reason: "module_missing" }; + } +} + +function applySkyboxConfig(THREE, scene, env, textureLoader) { + const sky = (env && typeof env.skybox === "object") ? env.skybox : null; + if (!sky || !toBoolean(sky.enabled, false) || !sky.uri) { + return Promise.resolve({ enabled: false, reason: "disabled", update: null }); + } + + const skyType = String(sky.type || "equirectangular").toLowerCase(); + if (skyType !== "equirectangular" && skyType !== "skydome") { + console.warn("[WebGLPack] Unsupported skybox type:", skyType); + return Promise.resolve({ enabled: false, reason: "unsupported_type", update: null }); + } + + return new Promise((resolve) => { + textureLoader.load( + sky.uri, + (tex) => { + if ("colorSpace" in tex) { + tex.colorSpace = THREE.SRGBColorSpace; + } else if ("encoding" in tex) { + tex.encoding = THREE.sRGBEncoding; + } + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.ClampToEdgeWrapping; + + if (skyType === "equirectangular") { + tex.mapping = THREE.EquirectangularReflectionMapping; + scene.background = tex; + if (toBoolean(sky.apply_environment, true)) { + scene.environment = tex; + } + resolve({ enabled: true, reason: "ok", update: null }); + return; + } + + // RenderPipeline default sky uses skydome projection (u = atan(x, y), v = z in Panda Z-up). + // In Web viewer (Three Y-up), equivalent direction is: + // u = atan(dir.x, -dir.z), v = dir.y. + const skyRadius = Math.max(1000, Number(sky.radius ?? 20000)); + const clipLowerHemisphere = toBoolean(sky.clip_lower_hemisphere, true); + const lowerColor = toColorArray(sky.lower_hemisphere_color, [0.08, 0.10, 0.13]); + const horizonBlend = THREE.MathUtils.clamp(Number(sky.horizon_blend ?? 0.06), 0.001, 0.35); + const horizonSampleV = THREE.MathUtils.clamp(Number(sky.horizon_sample_v ?? 0.01), 0.0, 0.2); + const lowerTintStrength = THREE.MathUtils.clamp(Number(sky.lower_tint_strength ?? 0.65), 0.0, 1.0); + const skyGeom = new THREE.SphereGeometry(skyRadius, 32, 16); + const skyMat = new THREE.ShaderMaterial({ + uniforms: { + uSkyTex: { value: tex }, + uRotationU: { value: Number(sky.rotation_u ?? 0.0) }, + uClipLowerHemisphere: { value: clipLowerHemisphere ? 1.0 : 0.0 }, + uLowerColor: { value: new THREE.Color(lowerColor[0], lowerColor[1], lowerColor[2]) }, + uHorizonBlend: { value: horizonBlend }, + uHorizonSampleV: { value: horizonSampleV }, + uLowerTintStrength: { value: lowerTintStrength }, + }, + vertexShader: ` + uniform float uRotationU; + varying vec2 vSkyUv; + varying float vDirY; + const float PI = 3.1415926535897932384626433832795; + void main() { + vec3 dir = normalize(position.xyz); + vDirY = dir.y; + float u = (atan(dir.x, -dir.z) + PI) / (2.0 * PI); + vSkyUv = vec2(fract(u + uRotationU), clamp(dir.y, 0.0, 1.0)); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform sampler2D uSkyTex; + uniform float uClipLowerHemisphere; + uniform vec3 uLowerColor; + uniform float uHorizonBlend; + uniform float uHorizonSampleV; + uniform float uLowerTintStrength; + varying vec2 vSkyUv; + varying float vDirY; + + void main() { + vec3 skyCol = texture2D(uSkyTex, vSkyUv).rgb; + if (uClipLowerHemisphere > 0.5) { + vec3 horizonCol = texture2D(uSkyTex, vec2(vSkyUv.x, uHorizonSampleV)).rgb; + vec3 lowerCol = mix(uLowerColor, horizonCol, uLowerTintStrength); + float t = smoothstep(-uHorizonBlend, uHorizonBlend, vDirY); + vec3 col = mix(lowerCol, skyCol, t); + gl_FragColor = vec4(col, 1.0); + } else { + gl_FragColor = vec4(skyCol, 1.0); + } + } + `, + side: THREE.BackSide, + depthWrite: false, + fog: false, + transparent: false, + }); + skyMat.toneMapped = false; + + const skydome = new THREE.Mesh(skyGeom, skyMat); + skydome.name = "WebGLPackSkydome"; + skydome.matrixAutoUpdate = true; + skydome.frustumCulled = false; + scene.add(skydome); + + if (!toBoolean(sky.apply_environment, false)) { + scene.environment = null; + } + + const update = (camera) => { + if (!camera) return; + skydome.position.copy(camera.position); + }; + resolve({ enabled: true, reason: "ok", update }); + }, + undefined, + (err) => { + console.warn("[WebGLPack] Skybox load failed:", sky.uri, err); + resolve({ enabled: false, reason: "load_failed", update: null }); + }, + ); + }); +} + function normalizeAnimationName(name) { return canonicalizeNameSegment(String(name ?? "")); } @@ -1055,6 +1294,465 @@ function setupModelAnimation(THREE, root, gltf, animationConfig, nodeName) { }; } +function toFiniteNumber(value, fallback) { + const v = Number(value); + return Number.isFinite(v) ? v : fallback; +} + +function toBoolean(value, fallback = true) { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const t = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(t)) return true; + if (["0", "false", "no", "off"].includes(t)) return false; + } + return fallback; +} + +function parseColorVec4(raw, fallback = [1, 1, 1, 1]) { + if (Array.isArray(raw) && raw.length >= 3) { + return [ + toFiniteNumber(raw[0], fallback[0]), + toFiniteNumber(raw[1], fallback[1]), + toFiniteNumber(raw[2], fallback[2]), + toFiniteNumber(raw[3], fallback[3]), + ]; + } + if (raw && typeof raw === "object") { + return [ + toFiniteNumber(raw.r, fallback[0]), + toFiniteNumber(raw.g, fallback[1]), + toFiniteNumber(raw.b, fallback[2]), + toFiniteNumber(raw.a, fallback[3]), + ]; + } + return fallback.slice(); +} + +function normalizeScriptName(name) { + const text = String(name ?? "").trim().replace(/\.py$/i, ""); + return canonicalizeNameSegment(text); +} + +function markObjectTransformDirty(obj) { + if (!obj || !obj.isObject3D) return; + obj.updateMatrix(); + obj.updateMatrixWorld(false); +} + +function applyPandaAxisDeltaToThreeLocal(obj, axis, delta) { + const ax = String(axis || "x").toLowerCase(); + if (ax === "x") { + obj.position.x += delta; + } else if (ax === "y") { + obj.position.z -= delta; + } else { + obj.position.y += delta; + } +} + +function ensureUniqueMaterialsForObject(root) { + if (!root || !root.traverse) return; + root.traverse((obj) => { + if (!obj.isMesh || !obj.material) return; + if (Array.isArray(obj.material)) { + obj.material = obj.material.map((mat) => { + if (!mat) return mat; + if (mat.userData?.__webglPackUniqueClone) return mat; + const cloned = mat.clone(); + cloned.userData = cloned.userData || {}; + cloned.userData.__webglPackUniqueClone = true; + return cloned; + }); + return; + } + const mat = obj.material; + if (!mat.userData?.__webglPackUniqueClone) { + const cloned = mat.clone(); + cloned.userData = cloned.userData || {}; + cloned.userData.__webglPackUniqueClone = true; + obj.material = cloned; + } + }); +} + +function applyColorToObject(root, rgba) { + if (!root || !root.traverse) return; + const r = toFiniteNumber(rgba?.[0], 1); + const g = toFiniteNumber(rgba?.[1], 1); + const b = toFiniteNumber(rgba?.[2], 1); + const a = Math.min(1, Math.max(0, toFiniteNumber(rgba?.[3], 1))); + + root.traverse((obj) => { + if (!obj.isMesh || !obj.material) return; + const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; + for (const mat of mats) { + if (!mat) continue; + if (mat.color) { + mat.color.setRGB(r, g, b); + } + if ("opacity" in mat) { + mat.opacity = a; + const transparent = a < 0.999; + mat.transparent = transparent; + mat.depthWrite = !transparent; + if (!transparent && "alphaTest" in mat) mat.alphaTest = 0; + } + mat.needsUpdate = true; + } + }); +} + +function hueToRgb(h) { + const hue = ((h % 1) + 1) % 1; + const i = Math.floor(hue * 6); + const f = hue * 6 - i; + const p = 0; + const q = 1 - f; + const t = f; + switch (i % 6) { + case 0: return [1, t, p]; + case 1: return [q, 1, p]; + case 2: return [p, 1, t]; + case 3: return [p, q, 1]; + case 4: return [t, p, 1]; + default: return [1, p, q]; + } +} + +function buildNodeNameLookup(nodeMap) { + const byCanonicalName = new Map(); + for (const obj of nodeMap.values()) { + const key = canonicalizeNameSegment(obj?.name ?? ""); + if (!key) continue; + if (!byCanonicalName.has(key)) { + byCanonicalName.set(key, []); + } + byCanonicalName.get(key).push(obj); + } + return byCanonicalName; +} + +function findDescendantByNameChain(root, chain) { + if (!root || !Array.isArray(chain) || chain.length === 0) return null; + let candidates = [root]; + for (const segment of chain) { + const token = canonicalizeNameSegment(segment ?? ""); + if (!token) continue; + const next = []; + for (const candidate of candidates) { + for (const child of candidate.children || []) { + const childToken = canonicalizeNameSegment(child?.name ?? ""); + if (childToken === token) { + next.push(child); + } + } + } + if (next.length === 0) return null; + candidates = next; + } + return candidates[0] || null; +} + +function resolveScriptNodeRef(refValue, nodeMap, nodeNameLookup) { + let ref = refValue; + if (ref && typeof ref === "object" && ref.__node_ref__ && typeof ref.__node_ref__ === "object") { + ref = ref.__node_ref__; + } + if (typeof ref === "string") { + const byId = nodeMap.get(ref); + if (byId) return byId; + const token = canonicalizeNameSegment(ref); + const byName = token ? (nodeNameLookup.get(token) || []) : []; + return byName.length > 0 ? byName[0] : null; + } + if (!ref || typeof ref !== "object") return null; + + const nodeId = String(ref.node_id ?? "").trim(); + if (nodeId && nodeMap.has(nodeId)) { + return nodeMap.get(nodeId); + } + + const ancestorId = String(ref.ancestor_node_id ?? "").trim(); + if (ancestorId && nodeMap.has(ancestorId) && Array.isArray(ref.child_name_chain)) { + const ancestor = nodeMap.get(ancestorId); + const desc = findDescendantByNameChain(ancestor, ref.child_name_chain); + if (desc) return desc; + } + + const nodeName = String(ref.node_name ?? "").trim(); + if (nodeName) { + const token = canonicalizeNameSegment(nodeName); + const byName = token ? (nodeNameLookup.get(token) || []) : []; + if (byName.length > 0) return byName[0]; + } + + return null; +} + +function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, ownerName) { + const scriptName = String(scriptCfg?.name ?? "").trim(); + if (!scriptName) return null; + const key = normalizeScriptName(scriptName); + const params = (scriptCfg?.params && typeof scriptCfg.params === "object") ? scriptCfg.params : {}; + const enabled = toBoolean(scriptCfg?.enabled, true); + + if (key === "moverscript" || key === "mover") { + const state = { + name: scriptName, + key, + enabled, + axis: String(params.move_axis ?? "x").toLowerCase(), + moveDistance: Math.max(0, Math.abs(toFiniteNumber(params.move_distance, 5.0))), + moveSpeed: toFiniteNumber(params.move_speed, 2.0), + currentDirection: toFiniteNumber(params.current_direction, 1) >= 0 ? 1 : -1, + currentDistance: Math.max(0, Math.abs(toFiniteNumber(params.current_distance, 0.0))), + isMoving: toBoolean(params.is_moving, true), + update(dt) { + if (!this.enabled || !this.isMoving) return; + const delta = this.moveSpeed * dt * this.currentDirection; + this.currentDistance += Math.abs(delta); + if (this.moveDistance > 1e-6 && this.currentDistance >= this.moveDistance) { + this.currentDirection *= -1; + this.currentDistance = 0; + } + applyPandaAxisDeltaToThreeLocal(object3d, this.axis, delta); + markObjectTransformDirty(object3d); + }, + }; + return state; + } + + if (key === "rotatorscript" || key === "rotator") { + return { + name: scriptName, + key, + enabled, + speedDeg: toFiniteNumber(params.rotation_speed_y, 30.0), + isRotating: toBoolean(params.is_rotating, true), + update(dt) { + if (!this.enabled || !this.isRotating) return; + object3d.rotation.y += THREE.MathUtils.degToRad(this.speedDeg * dt); + markObjectTransformDirty(object3d); + }, + }; + } + + if (key === "scalerscript" || key === "scaler") { + return { + name: scriptName, + key, + enabled, + baseScale: toFiniteNumber(params.base_scale, 1.0), + scaleAmplitude: toFiniteNumber(params.scale_amplitude, 0.3), + scaleSpeed: toFiniteNumber(params.scale_speed, 2.0), + uniformScale: toBoolean(params.uniform_scale, true), + timeAccumulator: toFiniteNumber(params.time_accumulator, 0.0), + isScaling: toBoolean(params.is_scaling, true), + update(dt) { + if (!this.enabled || !this.isScaling) return; + this.timeAccumulator += dt; + const sine = Math.sin(this.timeAccumulator * this.scaleSpeed * 2 * Math.PI); + const scaleFactor = this.baseScale + (this.scaleAmplitude * sine); + if (this.uniformScale) { + object3d.scale.setScalar(scaleFactor); + } else { + object3d.scale.y = scaleFactor; + } + markObjectTransformDirty(object3d); + }, + }; + } + + if (key === "colorchangerscript" || key === "colorchanger") { + ensureUniqueMaterialsForObject(object3d); + return { + name: scriptName, + key, + enabled, + colorSpeed: toFiniteNumber(params.color_speed, 1.0), + colorMode: String(params.color_mode ?? "rainbow").toLowerCase(), + baseColor: parseColorVec4(params.base_color, [1, 1, 1, 1]), + intensity: toFiniteNumber(params.intensity, 1.0), + timeAccumulator: toFiniteNumber(params.time_accumulator, 0.0), + isChanging: toBoolean(params.is_changing, true), + update(dt) { + if (!this.enabled || !this.isChanging) return; + this.timeAccumulator += dt; + + let rgba = this.baseColor.slice(); + if (this.colorMode === "rainbow") { + const rgb = hueToRgb(this.timeAccumulator * this.colorSpeed); + rgba = [rgb[0] * this.intensity, rgb[1] * this.intensity, rgb[2] * this.intensity, 1.0]; + } else if (this.colorMode === "pulse") { + const pulse = (Math.sin(this.timeAccumulator * this.colorSpeed * 2 * Math.PI) + 1.0) / 2.0; + const m = pulse * this.intensity; + rgba = [ + this.baseColor[0] * m, + this.baseColor[1] * m, + this.baseColor[2] * m, + this.baseColor[3], + ]; + } else if (this.colorMode === "fade") { + const fade = (Math.sin(this.timeAccumulator * this.colorSpeed * 2 * Math.PI) + 1.0) / 2.0; + rgba = [ + this.baseColor[0], + this.baseColor[1], + this.baseColor[2], + fade * this.intensity, + ]; + } else if (this.colorMode === "strobe") { + const safeSpeed = Math.max(0.0001, Math.abs(this.colorSpeed)); + const interval = 1.0 / (safeSpeed * 2.0); + const on = (Math.floor(this.timeAccumulator / interval) % 2) === 0; + rgba = on + ? [ + this.baseColor[0] * this.intensity, + this.baseColor[1] * this.intensity, + this.baseColor[2] * this.intensity, + this.baseColor[3], + ] + : [0.1, 0.1, 0.1, this.baseColor[3]]; + } + applyColorToObject(object3d, rgba); + }, + }; + } + + if (key === "bouncerscript" || key === "bouncer") { + return { + name: scriptName, + key, + enabled, + jumpHeight: toFiniteNumber(params.jump_height, 2.0), + jumpSpeed: toFiniteNumber(params.jump_speed, 3.0), + bounceType: String(params.bounce_type ?? "sine").toLowerCase(), + timeAccumulator: toFiniteNumber(params.time_accumulator, 0.0), + isBouncing: toBoolean(params.is_bouncing, true), + bounceDirection: toFiniteNumber(params.bounce_direction, 1) >= 0 ? 1 : -1, + originalHeight: toFiniteNumber(params.original_y, object3d.position.y), + update(dt) { + if (!this.enabled || !this.isBouncing) return; + this.timeAccumulator += dt * this.bounceDirection; + let offset = 0; + const sineValue = Math.sin(this.timeAccumulator * this.jumpSpeed * 2 * Math.PI); + if (this.bounceType === "sine") { + offset = sineValue * this.jumpHeight; + } else if (this.bounceType === "abs_sine") { + offset = Math.abs(sineValue) * this.jumpHeight; + } else if (this.bounceType === "square") { + offset = sineValue > 0 ? this.jumpHeight : 0; + } + object3d.position.y = this.originalHeight + offset; + markObjectTransformDirty(object3d); + }, + }; + } + + if (key === "followerscript" || key === "follower") { + const targetHint = Object.prototype.hasOwnProperty.call(params, "target") + ? params.target + : (Object.prototype.hasOwnProperty.call(params, "target_ref") ? params.target_ref : null); + return { + name: scriptName, + key, + enabled, + followSpeed: Math.max(0, toFiniteNumber(params.follow_speed, 5.0)), + followDistance: Math.max(0, toFiniteNumber(params.follow_distance, 2.0)), + isFollowing: toBoolean(params.is_following, true), + targetHint, + targetObject: null, + tempTargetPos: new THREE.Vector3(), + tempCurrentPos: new THREE.Vector3(), + tempMoveDir: new THREE.Vector3(), + update(dt) { + if (!this.enabled || !this.isFollowing) return; + if (!this.targetObject && this.targetHint) { + this.targetObject = resolveScriptNodeRef(this.targetHint, nodeMap, nodeNameLookup); + } + if (!this.targetObject) return; + + this.targetObject.getWorldPosition(this.tempTargetPos); + object3d.getWorldPosition(this.tempCurrentPos); + + this.tempMoveDir.subVectors(this.tempTargetPos, this.tempCurrentPos); + const distance = this.tempMoveDir.length(); + if (distance > this.followDistance && distance > 1e-6) { + this.tempMoveDir.normalize(); + const targetFollowPos = this.tempTargetPos.clone().addScaledVector(this.tempMoveDir, -this.followDistance); + const moveDirection = targetFollowPos.sub(this.tempCurrentPos); + const moveDistance = moveDirection.length(); + if (moveDistance > 1e-6) { + moveDirection.normalize(); + const moveAmount = Math.min(this.followSpeed * dt, moveDistance); + const newWorldPos = this.tempCurrentPos.clone().addScaledVector(moveDirection, moveAmount); + if (object3d.parent) { + object3d.parent.worldToLocal(newWorldPos); + } + object3d.position.copy(newWorldPos); + } + } + object3d.lookAt(this.tempTargetPos); + markObjectTransformDirty(object3d); + }, + }; + } + + if (key === "comboanimatorscript" || key === "comboanimator") { + return { + name: scriptName, + key, + enabled, + time: toFiniteNumber(params.time, 0.0), + isActive: toBoolean(params.is_active, true), + originalHeight: object3d.position.y, + update(dt) { + if (!this.enabled || !this.isActive) return; + this.time += dt; + object3d.rotation.y += THREE.MathUtils.degToRad(45.0 * dt); + object3d.position.y = this.originalHeight + Math.abs(Math.sin(this.time * 3.0)); + markObjectTransformDirty(object3d); + }, + }; + } + + console.warn("[WebGLPack] Unsupported script in Web viewer:", { + node: ownerName || object3d?.name || "(unnamed)", + script: scriptName, + }); + return null; +} + +function buildScriptRuntimeStates(THREE, nodes, nodeMap) { + const runtimes = []; + let unsupportedCount = 0; + const nodeNameLookup = buildNodeNameLookup(nodeMap); + + for (const node of nodes) { + const obj = nodeMap.get(node?.id); + if (!obj) continue; + const scripts = Array.isArray(node?.scripts) ? node.scripts : []; + for (const scriptCfg of scripts) { + const state = createScriptState( + THREE, + scriptCfg, + obj, + nodeMap, + nodeNameLookup, + node?.name || node?.id || "", + ); + if (state) { + runtimes.push(state); + } else { + unsupportedCount += 1; + } + } + } + + return { runtimes, unsupportedCount }; +} + async function bootstrap() { setStatus("Loading WebGL dependencies..."); @@ -1090,6 +1788,7 @@ async function bootstrap() { } const data = await response.json(); + const sceneNodes = Array.isArray(data.nodes) ? data.nodes : []; const renderer = new THREE.WebGLRenderer({ canvas, @@ -1142,6 +1841,12 @@ async function bootstrap() { } const env = data.environment || {}; + const rpProfile = (env.render_pipeline && typeof env.render_pipeline === "object") + ? env.render_pipeline + : {}; + const renderProfile = applyRenderPipelineApproximation(THREE, renderer, scene, rpProfile); + const textureLoader = new THREE.TextureLoader(); + const skyboxPromise = applySkyboxConfig(THREE, scene, env, textureLoader); if (env.ambient_light) { const c = toColorArray(env.ambient_light.color, [0.2, 0.2, 0.2]); @@ -1159,6 +1864,10 @@ async function bootstrap() { const dir = directionToThree(THREE, env.directional_light.direction, basis); dirLight.position.copy(dir.clone().multiplyScalar(-40)); dirLight.target.position.set(0, 0, 0); + dirLight.castShadow = !!renderProfile.shadowEnabled; + if (dirLight.shadow && dirLight.shadow.mapSize) { + dirLight.shadow.mapSize.set(renderProfile.shadowResolution, renderProfile.shadowResolution); + } scene.add(dirLight); scene.add(dirLight.target); @@ -1170,9 +1879,8 @@ async function bootstrap() { const animationStates = []; const gltfLoader = new GLTFLoader(); - const textureLoader = new THREE.TextureLoader(); - for (const node of Array.isArray(data.nodes) ? data.nodes : []) { + for (const node of sceneNodes) { let obj; if (node.kind === "point_light") { @@ -1183,6 +1891,10 @@ async function bootstrap() { Number(light.intensity ?? 1), Number(light.range ?? 0), ); + obj.castShadow = !!renderProfile.shadowEnabled; + if (obj.shadow && obj.shadow.mapSize) { + obj.shadow.mapSize.set(renderProfile.shadowResolution, renderProfile.shadowResolution); + } } else if (node.kind === "spot_light") { const light = node.light || {}; const c = toColorArray(light.color, [1, 1, 1]); @@ -1195,6 +1907,10 @@ async function bootstrap() { 1 - Number(light.inner_cone_ratio ?? 0.4), ); spot.target.position.set(0, 0, -1); + spot.castShadow = !!renderProfile.shadowEnabled; + if (spot.shadow && spot.shadow.mapSize) { + spot.shadow.mapSize.set(renderProfile.shadowResolution, renderProfile.shadowResolution); + } obj = spot; } else if (node.kind === "ground") { const g = node.ground || {}; @@ -1211,7 +1927,8 @@ async function bootstrap() { side: THREE.DoubleSide, }); obj = new THREE.Mesh(new THREE.PlaneGeometry(width, height), mat); - obj.receiveShadow = true; + obj.receiveShadow = !!renderProfile.shadowEnabled; + obj.castShadow = false; } else { obj = new THREE.Group(); const modelUri = node.model?.uri; @@ -1232,6 +1949,7 @@ async function bootstrap() { ); applyMaterialOverride(THREE, root, node.material_override || null); applyTextureOverrides(THREE, root, node.texture_overrides || [], textureLoader); + applyShadowFlags(root, renderProfile.shadowEnabled); const anim = setupModelAnimation( THREE, root, @@ -1277,7 +1995,7 @@ async function bootstrap() { nodeMap.set(node.id, obj); } - for (const node of Array.isArray(data.nodes) ? data.nodes : []) { + for (const node of sceneNodes) { const obj = nodeMap.get(node.id); if (!obj) continue; @@ -1294,6 +2012,18 @@ async function bootstrap() { } await Promise.all(pendingModelLoads); + const skyboxState = await skyboxPromise; + const bloomSetup = await tryCreateBloomComposer( + THREE, + renderer, + scene, + camera, + renderProfile.bloom, + ); + const composer = bloomSetup.composer; + const scriptRuntime = buildScriptRuntimeStates(THREE, sceneNodes, nodeMap); + const scriptStates = scriptRuntime.runtimes; + const unsupportedScriptCount = scriptRuntime.unsupportedCount; const resize = () => { const w = window.innerWidth; @@ -1301,13 +2031,16 @@ async function bootstrap() { camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); + if (composer && typeof composer.setSize === "function") { + composer.setSize(w, h); + } }; window.addEventListener("resize", resize); resize(); setStatus( - `Scene ready. Nodes: ${(data.nodes || []).length}, Animations: ${animationStates.length}.\nUse mouse to orbit, wheel to zoom.`, + `Scene ready. Nodes: ${sceneNodes.length}, Animations: ${animationStates.length}, Scripts: ${scriptStates.length}${unsupportedScriptCount > 0 ? ` (unsupported: ${unsupportedScriptCount})` : ""}, Skybox: ${skyboxState.enabled ? "on" : "off"}, ToneMap: ${renderProfile.toneMappingEnabled ? "on" : "off"}, Shadows: ${renderProfile.shadowEnabled ? "on" : "off"}, Fog: ${renderProfile.fogApplied ? "on" : "off"}, Bloom: ${bloomSetup.enabled ? "on" : "off"}.\nUse mouse to orbit, wheel to zoom.`, "ok", ); @@ -1323,8 +2056,23 @@ async function bootstrap() { // Keep render loop alive even if one mixer fails. } } + scene.updateMatrixWorld(true); + for (const state of scriptStates) { + try { + state.update(dt); + } catch (err) { + // Keep render loop alive even if one script fails. + } + } controls.update(); - renderer.render(scene, camera); + if (skyboxState && typeof skyboxState.update === "function") { + skyboxState.update(camera); + } + if (composer) { + composer.render(); + } else { + renderer.render(scene, camera); + } } };