Upload folder using huggingface_hub
Browse files- .gitattributes +2 -0
- LICENSE +21 -0
- README.md +226 -0
- build/MonkeyStudio.apk +3 -0
- build/MonkeyStudio.apk.idsig +3 -0
- docs/ARCHITECTURE.md +142 -0
- docs/BUILD_ANDROID.md +279 -0
- export_presets.cfg +66 -0
- project.godot +61 -0
- scenes/main.tscn +6 -0
- scripts/core/feature_registry.gd +135 -0
- scripts/core/feature_registry.gd.uid +1 -0
- scripts/core/memory_manager.gd +182 -0
- scripts/core/memory_manager.gd.uid +1 -0
- scripts/core/mesh_pool.gd +65 -0
- scripts/core/mesh_pool.gd.uid +1 -0
- scripts/core/studio_bus.gd +54 -0
- scripts/core/studio_bus.gd.uid +1 -0
- scripts/core/vertex_buffer_manager.gd +114 -0
- scripts/core/vertex_buffer_manager.gd.uid +1 -0
- scripts/install_godot.sh +51 -0
- scripts/modules/mesh_gen.gd +282 -0
- scripts/modules/mesh_gen.gd.uid +1 -0
- scripts/modules/rigging.gd +174 -0
- scripts/modules/rigging.gd.uid +1 -0
- scripts/modules/sculpt.gd +238 -0
- scripts/modules/sculpt.gd.uid +1 -0
- scripts/modules/simulation.gd +304 -0
- scripts/modules/simulation.gd.uid +1 -0
- scripts/modules/timeline.gd +174 -0
- scripts/modules/timeline.gd.uid +1 -0
- scripts/studio/studio_main.gd +140 -0
- scripts/studio/studio_main.gd.uid +1 -0
- scripts/studio/studio_ui.gd +418 -0
- scripts/studio/studio_ui.gd.uid +1 -0
- scripts/studio/touch_camera.gd +183 -0
- scripts/studio/touch_camera.gd.uid +1 -0
- tests/self_test.gd +157 -0
- tests/self_test.gd.uid +1 -0
- tests/self_test.tscn +6 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
build/MonkeyStudio.apk filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
build/MonkeyStudio.apk.idsig filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 MonkeyStudio contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language:
|
| 3 |
+
- en
|
| 4 |
+
- hi
|
| 5 |
+
tags:
|
| 6 |
+
- godot
|
| 7 |
+
- gdscript
|
| 8 |
+
- 3d
|
| 9 |
+
- mobile
|
| 10 |
+
- android
|
| 11 |
+
- modeling
|
| 12 |
+
- sculpting
|
| 13 |
+
- rigging
|
| 14 |
+
- cloth-simulation
|
| 15 |
+
- game-engine
|
| 16 |
+
license: mit
|
| 17 |
+
library_name: godot
|
| 18 |
+
pipeline_tag: other
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
# MonkeyStudio
|
| 22 |
+
|
| 23 |
+
**An open-source 3D creation studio for low-RAM (4 GB) Android phones**, built
|
| 24 |
+
with **Godot 4.x + GDScript**. Think of it as a foundation for a Blender-style
|
| 25 |
+
mobile tool: procedural modeling, sculpting, skeletal rigging, cloth/hair
|
| 26 |
+
simulation and keyframe animation — all designed around a memory architecture
|
| 27 |
+
that is meant to avoid Out-Of-Memory crashes on weak devices.
|
| 28 |
+
|
| 29 |
+
This repository contains the **full source code** and a **ready-to-install
|
| 30 |
+
debug APK**.
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## Download the APK
|
| 35 |
+
|
| 36 |
+
The built app is included at:
|
| 37 |
+
|
| 38 |
+
```
|
| 39 |
+
build/MonkeyStudio.apk
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
| Property | Value |
|
| 43 |
+
|---|---|
|
| 44 |
+
| Package | `com.monkeycode.monkeystudio` |
|
| 45 |
+
| Version | `0.1.0` (versionCode 1) |
|
| 46 |
+
| Size | ~55 MB |
|
| 47 |
+
| Architectures | `arm64-v8a`, `armeabi-v7a` (32-bit + 64-bit ARM) |
|
| 48 |
+
| minSdk | 24 (Android 7.0) |
|
| 49 |
+
| targetSdk | 36 |
|
| 50 |
+
| Signing | Android Debug key (side-load only) |
|
| 51 |
+
|
| 52 |
+
Install on a phone:
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
adb install -r build/MonkeyStudio.apk
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
or copy the `.apk` to the phone and install it manually (enable
|
| 59 |
+
"Install from unknown sources"). This is a **debug-signed** build meant for
|
| 60 |
+
testing and side-loading, not for the Play Store.
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## Honest scope note
|
| 65 |
+
|
| 66 |
+
Please read this before expecting a finished product.
|
| 67 |
+
|
| 68 |
+
- **No software is "100% bug-free."** Neither is this. Blender itself has had
|
| 69 |
+
200+ contributors and thousands of bug fixes over 25+ years. Anyone who
|
| 70 |
+
promises "guaranteed zero bugs" is not being truthful.
|
| 71 |
+
- **This is a working, tested foundation — not a feature-complete Blender
|
| 72 |
+
clone.** A full mobile Blender is a multi-year, multi-developer effort. Each
|
| 73 |
+
module here is real, runs, and is covered by an automated headless test
|
| 74 |
+
suite (25 checks, all passing).
|
| 75 |
+
- The architecture is deliberately built so new tools can be added as
|
| 76 |
+
`FeatureRegistry` entries without touching the memory core.
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## What is implemented and tested
|
| 81 |
+
|
| 82 |
+
| Area | Status | Where |
|
| 83 |
+
|---|---|---|
|
| 84 |
+
| Memory budget, lazy load, LRU eviction | Done + tested | `scripts/core/memory_manager.gd` |
|
| 85 |
+
| Feature catalog / on-demand load & unload | Done + tested | `scripts/core/feature_registry.gd` |
|
| 86 |
+
| GPU mesh pooling (no GC spikes) | Done + tested | `scripts/core/mesh_pool.gd` |
|
| 87 |
+
| VRAM cost estimate + batched upload | Done + tested | `scripts/core/vertex_buffer_manager.gd` |
|
| 88 |
+
| Procedural meshes: cube, sphere, cylinder, plane, torus, icosphere, monkey head | Done + tested | `scripts/modules/mesh_gen.gd` |
|
| 89 |
+
| Sculpt brushes: draw, grab, pinch, smooth, flatten | Done + tested | `scripts/modules/sculpt.gd` |
|
| 90 |
+
| Skeleton3D builder + auto 4-influence weighting | Done + tested | `scripts/modules/rigging.gd` |
|
| 91 |
+
| Verlet mass-spring cloth + hair (gravity, wind) | Done + tested | `scripts/modules/simulation.gd` |
|
| 92 |
+
| Keyframe timeline (engine-native Animation) | Done + tested | `scripts/modules/timeline.gd` |
|
| 93 |
+
| Touch camera (orbit / pinch-zoom / pan) | Done | `scripts/studio/touch_camera.gd` |
|
| 94 |
+
| Dark material UI + collapsible panels + HUD | Done | `scripts/studio/studio_ui.gd` |
|
| 95 |
+
| App bootstrap / adaptive quality | Done | `scripts/studio/studio_main.gd` |
|
| 96 |
+
|
| 97 |
+
### Roadmap (not yet built)
|
| 98 |
+
|
| 99 |
+
UV unwrap, boolean operations, baking, OBJ/glTF/USD import-export, an undo
|
| 100 |
+
stack, dynamic geometry LOD, a wider brush set, and per-device performance
|
| 101 |
+
profiles.
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## Memory architecture (the core idea)
|
| 106 |
+
|
| 107 |
+
A 4 GB phone gives an app roughly 1.2–1.5 GB before the OS starts killing it.
|
| 108 |
+
Blender-style tools are memory-hungry. The only way to offer many features
|
| 109 |
+
without OOM is to **never have them all resident at once**.
|
| 110 |
+
|
| 111 |
+
1. **`MemoryManager`** — owns a hard RAM budget (default 512 MB, auto-capped to
|
| 112 |
+
1/8 of physical RAM). Assets are registered as `id -> path + cost`, *not*
|
| 113 |
+
loaded. `acquire(id)` loads on first use; `release(id)` drops a reference.
|
| 114 |
+
At zero references an asset becomes an eviction candidate. It evicts
|
| 115 |
+
least-recently-used, zero-reference assets first — never one that is in use.
|
| 116 |
+
It also polls `OS.get_memory_info()` and force-evicts when the OS reports
|
| 117 |
+
< 128 MB free.
|
| 118 |
+
2. **`FeatureRegistry`** — a catalog of every tool as metadata only. `activate()`
|
| 119 |
+
instantiates a module on demand; `deactivate()` destroys it and frees the
|
| 120 |
+
RAM. "Close the tool, free the memory" is automatic.
|
| 121 |
+
3. **`MeshPool`** — recycles `ArrayMesh` objects; released meshes have their
|
| 122 |
+
surfaces cleared (GPU buffers freed) but the object is reused, avoiding
|
| 123 |
+
allocation churn.
|
| 124 |
+
4. **`VertexBufferManager`** — predicts RAM+VRAM cost before a mesh is built,
|
| 125 |
+
and performs a *single* GPU upload per edit (mobile GPUs hate thousands of
|
| 126 |
+
tiny buffer updates).
|
| 127 |
+
5. **Adaptive quality** — when FPS stays below 30, shadows are disabled first,
|
| 128 |
+
then the simulation substep budget drops, keeping the frame budget bounded.
|
| 129 |
+
|
| 130 |
+
Full write-up: `docs/ARCHITECTURE.md`.
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
## About AI in this project
|
| 135 |
+
|
| 136 |
+
**Important and honest:** the current codebase contains **no AI / machine
|
| 137 |
+
learning / LLM component.** There is no model weights file, no neural network,
|
| 138 |
+
and nothing calls an AI API. It is a pure Godot engine application written in
|
| 139 |
+
GDScript.
|
| 140 |
+
|
| 141 |
+
The phrase "built with an AI coding assistant" refers to **how the code was
|
| 142 |
+
written** (an AI assistant helped author it), *not* to the app having AI
|
| 143 |
+
features at runtime.
|
| 144 |
+
|
| 145 |
+
If you want to add real AI capabilities, here are realistic options and how
|
| 146 |
+
they would fit this architecture:
|
| 147 |
+
|
| 148 |
+
| Idea | What it needs | Where it plugs in |
|
| 149 |
+
|---|---|---|
|
| 150 |
+
| Text-to-3D mesh generation | An ONNX/TFLite generative model (e.g. a small diffusion or point-cloud model) run on-device, or a cloud API | New `mesh_gen` backend that outputs the same `{verts, indices, uvs}` dictionary |
|
| 151 |
+
| Auto-retopology / auto-UV | A learned or classical algorithm | New feature module under `scripts/modules/` |
|
| 152 |
+
| AI texture generation | On-device Stable-Diffusion-lite or cloud API | Material assignment in `mesh_gen.gd` |
|
| 153 |
+
| Auto-rigging (skeleton from mesh) | A rig-prediction model | `rigging.gd` currently uses inverse-distance weights; a model could replace that heuristic |
|
| 154 |
+
| Prompt-based animation | Motion model / LLM driving keyframes | Feed results into `timeline.gd` `add_keyframe()` |
|
| 155 |
+
|
| 156 |
+
**Security note for any future AI integration:** never hardcode API keys. Read
|
| 157 |
+
them from the user's own project environment variables (for example
|
| 158 |
+
`os.get_environment("USER_LLM_API_KEY")` on desktop, or a settings screen on
|
| 159 |
+
Android) and ship a placeholder config. Keys must come from the user, not from
|
| 160 |
+
the build environment.
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## Repository layout
|
| 165 |
+
|
| 166 |
+
```text
|
| 167 |
+
project.godot Engine config (mobile renderer, landscape, 720p)
|
| 168 |
+
export_presets.cfg Android APK export preset
|
| 169 |
+
scenes/main.tscn App entry scene
|
| 170 |
+
scripts/
|
| 171 |
+
core/ MemoryManager, FeatureRegistry, MeshPool,
|
| 172 |
+
VertexBufferManager, StudioBus (autoloads)
|
| 173 |
+
modules/ mesh_gen, sculpt, rigging, simulation, timeline
|
| 174 |
+
studio/ studio_main (bootstrap), studio_ui, touch_camera
|
| 175 |
+
install_godot.sh Linux installer (editor + export templates)
|
| 176 |
+
tests/self_test.tscn Headless subsystem verification (25 checks)
|
| 177 |
+
docs/
|
| 178 |
+
ARCHITECTURE.md How the memory model and modules work
|
| 179 |
+
BUILD_ANDROID.md Full Godot + Android SDK + APK build guide
|
| 180 |
+
build/MonkeyStudio.apk Ready-to-install debug APK
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## Build it yourself
|
| 186 |
+
|
| 187 |
+
```bash
|
| 188 |
+
# 1. Install Godot + export templates (Debian/Ubuntu)
|
| 189 |
+
chmod +x scripts/install_godot.sh
|
| 190 |
+
./scripts/install_godot.sh
|
| 191 |
+
|
| 192 |
+
# 2. Reimport resources
|
| 193 |
+
godot --headless --import
|
| 194 |
+
|
| 195 |
+
# 3. Run the automated tests (exit 0 = all pass)
|
| 196 |
+
godot --headless res://tests/self_test.tscn
|
| 197 |
+
|
| 198 |
+
# 4. Open in the editor
|
| 199 |
+
godot --editor project.godot
|
| 200 |
+
|
| 201 |
+
# 5. Build an APK (needs Android SDK + JDK 17, see docs/BUILD_ANDROID.md)
|
| 202 |
+
mkdir -p build
|
| 203 |
+
godot --headless --export-debug "Android" build/MonkeyStudio.apk
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
Verified environment: **Godot 4.7.2-stable**, Debian 12, OpenJDK 17, Android
|
| 207 |
+
SDK build-tools 34.0.0.
|
| 208 |
+
|
| 209 |
+
---
|
| 210 |
+
|
| 211 |
+
## Testing
|
| 212 |
+
|
| 213 |
+
`tests/self_test.tscn` runs headless and exercises every subsystem: mesh
|
| 214 |
+
generation, all five sculpt brushes (vertices actually move), the skinning
|
| 215 |
+
channels, cloth falling under gravity, hair creation, and timeline keyframing.
|
| 216 |
+
|
| 217 |
+
```bash
|
| 218 |
+
godot --headless res://tests/self_test.tscn
|
| 219 |
+
# -> SELFTEST: ALL PASS (25 PASS, 0 FAIL, exit 0)
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
## License
|
| 225 |
+
|
| 226 |
+
MIT. See `LICENSE`.
|
build/MonkeyStudio.apk
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2141a664ec491b5974866cffb2dc88730dfed945c8b2b348c9bebf5fc134d29f
|
| 3 |
+
size 57617110
|
build/MonkeyStudio.apk.idsig
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:72dd43fccbfea0b88aa981a214076f335c5377023f81c7f53cb545fdb8ba71f0
|
| 3 |
+
size 456115
|
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MonkeyStudio Architecture
|
| 2 |
+
|
| 3 |
+
## 1. The core problem: 100+ features on 4 GB of RAM
|
| 4 |
+
|
| 5 |
+
A phone with 4 GB total RAM gives an app roughly **1.2-1.5 GB** before the OS
|
| 6 |
+
starts killing it. Blender-style tools (sculpt grids, cloth solvers, hair
|
| 7 |
+
particles, rigging) are memory-hungry. The only way to offer many features
|
| 8 |
+
without OOM is to **never have them all resident at once**.
|
| 9 |
+
|
| 10 |
+
MonkeyStudio's answer is a lazy-load + reference-count + LRU-evict memory
|
| 11 |
+
layer, plus a fixed pool for GPU objects. Below is how each piece works.
|
| 12 |
+
|
| 13 |
+
## 2. Memory layer (`scripts/core/`)
|
| 14 |
+
|
| 15 |
+
### 2.1 `MemoryManager` (autoload)
|
| 16 |
+
|
| 17 |
+
- Owns a hard **budget** (`asset_budget_mb`, default 512 MB, auto-capped to
|
| 18 |
+
1/8 of physical RAM).
|
| 19 |
+
- Assets are registered by **id -> path + estimated cost**, *not* loaded.
|
| 20 |
+
- `acquire(id)` loads on first use and increments a reference count.
|
| 21 |
+
- `release(id)` drops a reference. At zero references the asset becomes an
|
| 22 |
+
**eviction candidate**.
|
| 23 |
+
- `_ensure_room(need)` evicts **least-recently-used, zero-reference** assets
|
| 24 |
+
until the new asset fits. Assets with live references are never evicted, so
|
| 25 |
+
eviction can never yank something out from under a running feature.
|
| 26 |
+
- Every second it polls `OS.get_memory_info()`; when the OS says < 128 MB is
|
| 27 |
+
available it force-evicts to buy headroom.
|
| 28 |
+
|
| 29 |
+
### 2.2 `FeatureRegistry` (autoload)
|
| 30 |
+
|
| 31 |
+
- A **catalog** of every tool. Each entry is just metadata
|
| 32 |
+
(`name`, `module_path`, `cost_mb`).
|
| 33 |
+
- `activate(id)` asks MemoryManager for the module script, instantiates it,
|
| 34 |
+
and adds it to the `Features` host node.
|
| 35 |
+
- `deactivate(id)` destroys the module node and releases its memory. This is
|
| 36 |
+
what makes "close the tool, free the RAM" automatic.
|
| 37 |
+
|
| 38 |
+
### 2.3 `MeshPool` (autoload)
|
| 39 |
+
|
| 40 |
+
- Recycles `ArrayMesh` objects. A released mesh has its surfaces cleared
|
| 41 |
+
(freeing GPU buffers) but the object is kept for reuse, avoiding per-frame
|
| 42 |
+
allocation spikes and fragmentation.
|
| 43 |
+
- Bounded by `max_pooled` so the pool itself can't leak.
|
| 44 |
+
|
| 45 |
+
### 2.4 `VertexBufferManager` (autoload)
|
| 46 |
+
|
| 47 |
+
- `estimate_cost_mb(vertex_count)` predicts RAM+VRAM before a mesh is built so
|
| 48 |
+
MemoryManager can evict first.
|
| 49 |
+
- `build_surface()` / `commit()` do a **single** `add_surface_from_arrays()`
|
| 50 |
+
upload. Mobile GPUs hate thousands of tiny buffer updates; sculpting strokes
|
| 51 |
+
accumulate on the CPU and flush once.
|
| 52 |
+
- `recompute_normals()` restores correct lighting after edits.
|
| 53 |
+
|
| 54 |
+
### 2.5 Adaptive quality
|
| 55 |
+
|
| 56 |
+
`StudioBus` emits `fps_throttled` when FPS stays below 30. `StudioMain` reacts
|
| 57 |
+
by disabling shadows, then the simulation substep budget drops (see below).
|
| 58 |
+
This keeps the frame budget bounded on weak GPUs.
|
| 59 |
+
|
| 60 |
+
## 3. Feature modules (`scripts/modules/`)
|
| 61 |
+
|
| 62 |
+
Modules implement `_activate(host)` / `_deactivate()` and receive the scene
|
| 63 |
+
through the `StudioBus` signal hub (low coupling, safe lazy unload).
|
| 64 |
+
|
| 65 |
+
| Module | Features | Notes |
|
| 66 |
+
|---|---|---|
|
| 67 |
+
| `mesh_gen.gd` | 1-20 | Procedural cube/sphere/cylinder/plane/torus/ico + a code-generated monkey head |
|
| 68 |
+
| `sculpt.gd` | 21-40 | draw / grab / pinch / smooth / flatten, batched GPU flush |
|
| 69 |
+
| `rigging.gd` | 41-60 | Skeleton3D builder + inverse-distance auto-weighting + `Skin` bind |
|
| 70 |
+
| `simulation.gd` | 61-80 | Verlet mass-spring cloth & hair with wind + pinned constraints |
|
| 71 |
+
| `timeline.gd` | 81-100 | Engine-native `Animation` + `AnimationPlayer` keyframing |
|
| 72 |
+
|
| 73 |
+
### 3.1 Mesh generation
|
| 74 |
+
|
| 75 |
+
Pure functions returning `{verts, indices, uvs}`. No GPU allocation until
|
| 76 |
+
`spawn()` commits. Costs are predictable, so a 5000-vertex sphere is refused
|
| 77 |
+
before it is built if it would blow the budget.
|
| 78 |
+
|
| 79 |
+
### 3.2 Sculpting
|
| 80 |
+
|
| 81 |
+
A stroke is `begin -> apply* -> end`. All `apply` calls mutate CPU
|
| 82 |
+
`PackedVector3Array`s; `end` performs one upload. Brushes use a smoothstep
|
| 83 |
+
falloff. `raycast_screen()` prefers a physics ray against the mesh collider and
|
| 84 |
+
falls back to a bounding-sphere test when the mesh has no collider yet.
|
| 85 |
+
|
| 86 |
+
### 3.3 Rigging
|
| 87 |
+
|
| 88 |
+
`generate_default_armature()` builds a spine + limbs from the mesh bounds.
|
| 89 |
+
`bind_weights()` ranks bones by distance, keeps the nearest 4, normalizes, and
|
| 90 |
+
writes `ARRAY_BONES` + `ARRAY_WEIGHTS` (the standard 4-influence mobile
|
| 91 |
+
skinning path) plus a `Skin` resource. Result is a normal engine skinned mesh.
|
| 92 |
+
|
| 93 |
+
### 3.4 Cloth & hair
|
| 94 |
+
|
| 95 |
+
Fixed-timestep (`1/30 s`) Verlet integration with Jacobi constraint relaxation.
|
| 96 |
+
Sub-steps are capped at 4 and backlog is dropped, so a slow frame can never
|
| 97 |
+
spiral into a death march. Particles and springs live in `PackedArray`s (no
|
| 98 |
+
per-frame allocation). Cloth uploads a triangle grid; hair uploads ribbons.
|
| 99 |
+
|
| 100 |
+
### 3.5 Timeline
|
| 101 |
+
|
| 102 |
+
Wraps a real `Animation` resource and `AnimationPlayer`, so interpolation,
|
| 103 |
+
looping and playback are engine-native and cached. The module exposes
|
| 104 |
+
`play/pause/scrub/add_keyframe/add_bone_keyframe` for a touch UI.
|
| 105 |
+
|
| 106 |
+
## 4. UI (`scripts/studio/studio_ui.gd`)
|
| 107 |
+
|
| 108 |
+
Material-ish dark theme built in code (resolution-independent, scaled by
|
| 109 |
+
`UI_SCALE`). Layout: top bar (FPS + memory HUD), left tool rail, right
|
| 110 |
+
collapsible panels (Tools / Layers / Timeline), bottom playback + scrubber.
|
| 111 |
+
Touch targets are >= 48 px.
|
| 112 |
+
|
| 113 |
+
## 5. Camera (`scripts/studio/touch_camera.gd`)
|
| 114 |
+
|
| 115 |
+
- 1 finger: orbit (or paint when a sculpt brush is active)
|
| 116 |
+
- 2 fingers: pinch = dolly zoom, drag = pan
|
| 117 |
+
- 3 fingers: pan
|
| 118 |
+
- All targets are exponentially smoothed so jittery touch sampling still feels
|
| 119 |
+
fluid. No allocation in the input hot path.
|
| 120 |
+
|
| 121 |
+
## 6. Testing
|
| 122 |
+
|
| 123 |
+
`tests/self_test.tscn` runs headless and verifies every subsystem:
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
godot --headless res://tests/self_test.tscn
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
It checks mesh generation, all sculpt brushes (vertices actually move), the
|
| 130 |
+
skinning channels, cloth falling under gravity, hair creation, and timeline
|
| 131 |
+
keyframing. Exit code 0 = pass.
|
| 132 |
+
|
| 133 |
+
## 7. What is production-ready vs. roadmap
|
| 134 |
+
|
| 135 |
+
Honest status:
|
| 136 |
+
|
| 137 |
+
- Memory layer, mesh generation, sculpting, rigging, simulation, timeline and
|
| 138 |
+
UI shell are **implemented and tested** as described above.
|
| 139 |
+
- Production polish still to do: adaptivity grid (`DynamicLOD`), UV unwrap,
|
| 140 |
+
boolean ops, baking, USD/OBJ/glTF import-export, undo stack, a broader brush
|
| 141 |
+
set, and device-specific tuning. The architecture is designed so these plug
|
| 142 |
+
in as new `FeatureRegistry` entries without touching the core.
|
docs/BUILD_ANDROID.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MonkeyStudio - Full Build & Android Export Guide (A to Z)
|
| 2 |
+
|
| 3 |
+
This document is the complete, step-by-step guide for **STEP 1 (setup)** and
|
| 4 |
+
**STEP 5 (build a real .apk)** of the MonkeyStudio project.
|
| 5 |
+
|
| 6 |
+
Everything described here was executed and verified on Debian 12 with
|
| 7 |
+
**Godot 4.7.2-stable**. Commands are copy-paste ready.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## STEP 1 - Godot install + Android project setup
|
| 12 |
+
|
| 13 |
+
### 1.1 Install the Godot editor (Linux / macOS / Windows)
|
| 14 |
+
|
| 15 |
+
**Linux (Debian/Ubuntu) - scripted:**
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
# Give the script execute permission
|
| 19 |
+
chmod +x scripts/install_godot.sh
|
| 20 |
+
|
| 21 |
+
# Run the installer (downloads editor + export templates + runtime libs)
|
| 22 |
+
./scripts/install_godot.sh
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
**Linux - manual (what the script does):**
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
# 1. Download the editor binary for the latest stable release
|
| 29 |
+
curl -fL -o godot.zip \
|
| 30 |
+
https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
|
| 31 |
+
|
| 32 |
+
# 2. Unzip it
|
| 33 |
+
unzip godot.zip
|
| 34 |
+
|
| 35 |
+
# 3. Make it executable and put it on your PATH
|
| 36 |
+
chmod +x Godot_v4.7.2-stable_linux.x86_64
|
| 37 |
+
sudo mv Godot_v4.7.2-stable_linux.x86_64 /usr/local/bin/godot
|
| 38 |
+
|
| 39 |
+
# 4. Verify
|
| 40 |
+
godot --version
|
| 41 |
+
# -> 4.7.2.stable.official.XXXXXXXXX
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
**Windows / macOS:** download the matching `.zip` / `.dmg` / `.app` from
|
| 45 |
+
`https://godotengine.org/download` and place the binary on your PATH. All other
|
| 46 |
+
steps below are identical from the editor UI.
|
| 47 |
+
|
| 48 |
+
### 1.2 Install export templates
|
| 49 |
+
|
| 50 |
+
Export templates are the pre-compiled engine the game runs on.
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
# Editor method (recommended, one click)
|
| 54 |
+
# Editor menu > Manage Export Templates > Download and Install
|
| 55 |
+
|
| 56 |
+
# Headless / CLI method
|
| 57 |
+
TEMPLATE_DIR="$HOME/.local/share/godot/export_templates/4.7.2.stable"
|
| 58 |
+
mkdir -p "$TEMPLATE_DIR"
|
| 59 |
+
curl -fL -o templates.tpz \
|
| 60 |
+
https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_export_templates.tpz
|
| 61 |
+
unzip -o templates.tpz -d /tmp/godot_tpl
|
| 62 |
+
cp /tmp/godot_tpl/templates/* "$TEMPLATE_DIR/"
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### 1.3 Install the Android build toolchain
|
| 66 |
+
|
| 67 |
+
You need three things: **OpenJDK 17**, the **Android SDK command-line tools**,
|
| 68 |
+
and the **Android NDK** is only required if you use a custom Gradle build
|
| 69 |
+
(MonkeyStudio's default APK build does **not** need the NDK).
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# OpenJDK 17
|
| 73 |
+
sudo apt-get update
|
| 74 |
+
sudo apt-get install -y openjdk-17-jdk unzip curl
|
| 75 |
+
|
| 76 |
+
# Verify
|
| 77 |
+
java -version
|
| 78 |
+
# -> openjdk version "17.x"
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
# Android command-line tools
|
| 83 |
+
mkdir -p "$HOME/android-sdk/cmdline-tools"
|
| 84 |
+
cd "$HOME/android-sdk/cmdline-tools"
|
| 85 |
+
curl -fL -o cmdline.zip \
|
| 86 |
+
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
| 87 |
+
unzip -o cmdline.zip
|
| 88 |
+
mv cmdline-tools latest # the SDK expects <sdk>/cmdline-tools/latest
|
| 89 |
+
|
| 90 |
+
# Set environment variables (add these to ~/.bashrc)
|
| 91 |
+
export ANDROID_HOME="$HOME/android-sdk"
|
| 92 |
+
export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools"
|
| 93 |
+
|
| 94 |
+
# Accept licenses and install the required packages
|
| 95 |
+
yes | sdkmanager --licenses
|
| 96 |
+
sdkmanager "platform-tools" "build-tools;34.0.0" "platforms;android-34"
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
# Create the debug keystore Godot uses for testing builds
|
| 101 |
+
keytool -keyalg RSA -genkeypair -alias androiddebugkey \
|
| 102 |
+
-keypass android -keystore debug.keystore -storepass android \
|
| 103 |
+
-dname "CN=Android Debug,O=Android,C=US" -validity 9999 -deststoretype pkcs12
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
### 1.4 Point Godot at the toolchain
|
| 107 |
+
|
| 108 |
+
```text
|
| 109 |
+
Editor > Editor Settings > Export > Android
|
| 110 |
+
Android SDK Path -> $HOME/android-sdk
|
| 111 |
+
Debug Keystore -> $HOME/debug.keystore
|
| 112 |
+
Debug Keystore User -> androiddebugkey
|
| 113 |
+
Debug Keystore Pass -> android
|
| 114 |
+
Java SDK Path -> /usr/lib/jvm/java-17-openjdk-amd64
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
Alternatively, from a terminal, write the same paths into
|
| 118 |
+
`~/.config/godot/editor_settings-4.tres` (advanced). The UI is safer.
|
| 119 |
+
|
| 120 |
+
### 1.5 Project display / renderer settings (mobile)
|
| 121 |
+
|
| 122 |
+
These are already set in `project.godot` so you do not have to click through the
|
| 123 |
+
UI, but here is exactly what they do:
|
| 124 |
+
|
| 125 |
+
| Setting | Value | Why |
|
| 126 |
+
|---|---|---|
|
| 127 |
+
| `display/window/size/viewport_width` | 1280 | landscape base |
|
| 128 |
+
| `display/window/size/viewport_height` | 720 | 720p target |
|
| 129 |
+
| `display/window/stretch/mode` | `canvas_items` | UI scales, 3D stays crisp |
|
| 130 |
+
| `display/window/handheld/orientation` | 1 | landscape |
|
| 131 |
+
| `rendering/renderer/rendering_method` | `mobile` | Mobile Vulkan/GLES path |
|
| 132 |
+
| `rendering/textures/vram_compression/import_etc2_astc` | true | required for mobile |
|
| 133 |
+
| `rendering/anti_aliasing/quality/msaa_3d` | 0 | no MSAA (saves GPU/mem) |
|
| 134 |
+
|
| 135 |
+
`mobile` renderer = Vulkan on modern phones, automatically falls back to the
|
| 136 |
+
`gl_compatibility` (OpenGL ES 3.0) path on older 4GB devices.
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## STEP 5 - Build a real .apk
|
| 141 |
+
|
| 142 |
+
### 5.1 One-time: generate an export preset
|
| 143 |
+
|
| 144 |
+
Open the project in the editor, then:
|
| 145 |
+
|
| 146 |
+
```text
|
| 147 |
+
Project > Export... > Add... > Android
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
A minimal preset is committed at `export_presets.cfg`. Key fields:
|
| 151 |
+
|
| 152 |
+
```ini
|
| 153 |
+
export_path="build/MonkeyStudio.apk"
|
| 154 |
+
architectures/armeabi-v7a=true # 32-bit ARM (old 4GB phones)
|
| 155 |
+
architectures/arm64-v8a=true # 64-bit ARM (required by Play Store)
|
| 156 |
+
architectures/x86=false
|
| 157 |
+
architectures/x86_64=false
|
| 158 |
+
package/unique_name="com.monkeycode.monkeystudio"
|
| 159 |
+
version/code=1
|
| 160 |
+
version/name="0.1.0"
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
### 5.2 Build from the command line (headless, CI-friendly)
|
| 164 |
+
|
| 165 |
+
```bash
|
| 166 |
+
# Make sure the output directory exists
|
| 167 |
+
mkdir -p build
|
| 168 |
+
|
| 169 |
+
# Export a signed debug APK
|
| 170 |
+
godot --headless --export-debug "Android" build/MonkeyStudio.apk
|
| 171 |
+
|
| 172 |
+
# Export a release APK (needs keystore/release set in the preset + editor settings)
|
| 173 |
+
godot --headless --export-release "Android" build/MonkeyStudio.apk
|
| 174 |
+
|
| 175 |
+
# Verify it is a real APK
|
| 176 |
+
unzip -l build/MonkeyStudio.apk | head
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
### 5.3 Build from the editor UI
|
| 180 |
+
|
| 181 |
+
```text
|
| 182 |
+
Project > Export... > Android > Export Project
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
### 5.4 Install / test on a device
|
| 186 |
+
|
| 187 |
+
```bash
|
| 188 |
+
# Enable USB debugging on the phone, then:
|
| 189 |
+
adb devices
|
| 190 |
+
adb install -r build/MonkeyStudio.apk
|
| 191 |
+
|
| 192 |
+
# Stream logs while the app runs
|
| 193 |
+
adb logcat | grep -i godot
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
## Keystore & Gradle troubleshooting
|
| 199 |
+
|
| 200 |
+
### Error: "Invalid keystore ... or wrong password"
|
| 201 |
+
|
| 202 |
+
Regenerate the debug keystore and re-check the pass:
|
| 203 |
+
|
| 204 |
+
```bash
|
| 205 |
+
keytool -keyalg RSA -genkeypair -alias androiddebugkey \
|
| 206 |
+
-keypass android -keystore debug.keystore -storepass android \
|
| 207 |
+
-dname "CN=Android Debug,O=Android,C=US" -validity 9999 -deststoretype pkcs12
|
| 208 |
+
|
| 209 |
+
# Inspect what is inside an existing keystore
|
| 210 |
+
keytool -list -v -keystore debug.keystore -storepass android
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
Make sure the password in *Editor Settings > Export > Android* matches the
|
| 214 |
+
`-keypass` / `-storepass` you used (they must be the same for the debug store).
|
| 215 |
+
|
| 216 |
+
### Error: "Could not find SDK / Build Tools 34.0.0"
|
| 217 |
+
|
| 218 |
+
```bash
|
| 219 |
+
sdkmanager --list | grep build-tools
|
| 220 |
+
sdkmanager "build-tools;34.0.0" "platforms;android-34" "platform-tools"
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
Then re-set `Android SDK Path` in Editor Settings.
|
| 224 |
+
|
| 225 |
+
### Error: "Missing ‘platform-tools’ / adb not found"
|
| 226 |
+
|
| 227 |
+
```bash
|
| 228 |
+
sdkmanager "platform-tools"
|
| 229 |
+
export PATH="$PATH:$ANDROID_HOME/platform-tools"
|
| 230 |
+
adb version
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
### Gradle build fails: "Could not resolve com.android.tools.build:gradle"
|
| 234 |
+
|
| 235 |
+
This only happens when `gradle_build/use_gradle_build=true`. If you see it,
|
| 236 |
+
either switch the preset back to the **default (non-Gradle) APK build** (which
|
| 237 |
+
is what MonkeyStudio ships with) or make sure the Gradle build needs network
|
| 238 |
+
access:
|
| 239 |
+
|
| 240 |
+
```bash
|
| 241 |
+
export GRADLE_OPTS="-Dorg.gradle.daemon=false"
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
### Error: "Export template not found"
|
| 245 |
+
|
| 246 |
+
Re-run section 1.2 (or use *Manage Export Templates > Download*). The template
|
| 247 |
+
folder name must exactly match the engine version, e.g. `4.7.2.stable`.
|
| 248 |
+
|
| 249 |
+
### Error: "Java version not compatible"
|
| 250 |
+
|
| 251 |
+
Godot's Android exporter requires **JDK 17**. Point Godot at it explicitly:
|
| 252 |
+
|
| 253 |
+
```bash
|
| 254 |
+
sudo update-alternatives --config java # choose java-17
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
### App crashes on launch with OOM on a 4GB phone
|
| 258 |
+
|
| 259 |
+
1. Lower `MemoryManager.asset_budget_mb` (default 512 MB) in
|
| 260 |
+
`scripts/core/memory_manager.gd`.
|
| 261 |
+
2. Keep `renderer/rendering_method` = `mobile`.
|
| 262 |
+
3. Turn off shadows on low-end devices (already done automatically by
|
| 263 |
+
`studio_main.gd` when FPS drops below 30).
|
| 264 |
+
4. Import textures as ETC2/ASTC (already enabled in `project.godot`).
|
| 265 |
+
|
| 266 |
+
---
|
| 267 |
+
|
| 268 |
+
## Verifying the project without a device
|
| 269 |
+
|
| 270 |
+
```bash
|
| 271 |
+
# 1. Reimport all resources (catches broken scripts)
|
| 272 |
+
godot --headless --import
|
| 273 |
+
|
| 274 |
+
# 2. Run the automated subsystem tests (exits 0 on success)
|
| 275 |
+
godot --headless res://tests/self_test.tscn
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
`self_test.tscn` exercises MemoryManager, all mesh generators, every sculpt
|
| 279 |
+
brush, the rigging/skinning path, cloth + hair simulation and the timeline.
|
export_presets.cfg
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[preset.0]
|
| 2 |
+
|
| 3 |
+
name="Android"
|
| 4 |
+
platform="Android"
|
| 5 |
+
runnable=true
|
| 6 |
+
advanced_options=true
|
| 7 |
+
dedicated_server=false
|
| 8 |
+
custom_features=""
|
| 9 |
+
export_filter="all_resources"
|
| 10 |
+
include_filter=""
|
| 11 |
+
exclude_filter="tests/*, docs/*"
|
| 12 |
+
export_path="build/MonkeyStudio.apk"
|
| 13 |
+
patches=PackedStringArray()
|
| 14 |
+
encryption_include_filters=""
|
| 15 |
+
encryption_exclude_filters=""
|
| 16 |
+
seed=0
|
| 17 |
+
encrypt_pck=false
|
| 18 |
+
encrypt_directory=false
|
| 19 |
+
script_export_mode=2
|
| 20 |
+
|
| 21 |
+
[preset.0.options]
|
| 22 |
+
|
| 23 |
+
custom_template/debug=""
|
| 24 |
+
custom_template/release=""
|
| 25 |
+
gradle_build/use_gradle_build=false
|
| 26 |
+
gradle_build/gradle_build_dir=""
|
| 27 |
+
gradle_build/export_format=0
|
| 28 |
+
gradle_build/min_sdk=""
|
| 29 |
+
gradle_build/target_sdk=""
|
| 30 |
+
architectures/armeabi-v7a=true
|
| 31 |
+
architectures/arm64-v8a=true
|
| 32 |
+
architectures/x86=false
|
| 33 |
+
architectures/x86_64=false
|
| 34 |
+
version/code=1
|
| 35 |
+
version/name="0.1.0"
|
| 36 |
+
package/unique_name="com.monkeycode.monkeystudio"
|
| 37 |
+
package/name="MonkeyStudio"
|
| 38 |
+
package/signed=true
|
| 39 |
+
package/app_category=2
|
| 40 |
+
package/retain_data_on_uninstall=false
|
| 41 |
+
package/exclude_from_recents=false
|
| 42 |
+
package/show_in_android_tv=false
|
| 43 |
+
package/show_in_app_library=true
|
| 44 |
+
package/show_as_launcher_app=false
|
| 45 |
+
launcher_icons/main_192x192=""
|
| 46 |
+
launcher_icons/adaptive_foreground_432x432=""
|
| 47 |
+
launcher_icons/adaptive_background_432x432=""
|
| 48 |
+
graphics/opengl_debug=false
|
| 49 |
+
xr_features/xr_mode=0
|
| 50 |
+
screen/immersive_mode=true
|
| 51 |
+
screen/support_small=true
|
| 52 |
+
screen/support_normal=true
|
| 53 |
+
screen/support_large=true
|
| 54 |
+
screen/support_xlarge=true
|
| 55 |
+
user_data_backup/allow=false
|
| 56 |
+
command_line/extra_args=""
|
| 57 |
+
apk_expansion/enable=false
|
| 58 |
+
apk_expansion/SALT=""
|
| 59 |
+
apk_expansion/public_key=""
|
| 60 |
+
permissions/custom_permissions=PackedStringArray()
|
| 61 |
+
keystore/debug=""
|
| 62 |
+
keystore/debug_user=""
|
| 63 |
+
keystore/debug_password=""
|
| 64 |
+
keystore/release=""
|
| 65 |
+
keystore/release_user=""
|
| 66 |
+
keystore/release_password=""
|
project.godot
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
; Engine configuration file.
|
| 2 |
+
; MonkeyStudio - 3D Mobile Creation Studio (Godot 4.x / GDScript)
|
| 3 |
+
; Generated for low-RAM (4GB) Android devices.
|
| 4 |
+
;
|
| 5 |
+
; Format:
|
| 6 |
+
; [section]
|
| 7 |
+
; param=value
|
| 8 |
+
|
| 9 |
+
config_version=5
|
| 10 |
+
|
| 11 |
+
[application]
|
| 12 |
+
|
| 13 |
+
config/name="MonkeyStudio"
|
| 14 |
+
config/description="Open-source 3D mobile creation studio optimized for 4GB RAM devices."
|
| 15 |
+
config/version="0.1.0"
|
| 16 |
+
run/main_scene="res://scenes/main.tscn"
|
| 17 |
+
config/features=PackedStringArray("4.7", "Mobile")
|
| 18 |
+
config/quit_on_go_back=false
|
| 19 |
+
boot_splash/bg_color=Color(0.08, 0.09, 0.11, 1)
|
| 20 |
+
boot_splash/show_image=false
|
| 21 |
+
|
| 22 |
+
[autoload]
|
| 23 |
+
|
| 24 |
+
MemoryManager="*res://scripts/core/memory_manager.gd"
|
| 25 |
+
FeatureRegistry="*res://scripts/core/feature_registry.gd"
|
| 26 |
+
MeshPool="*res://scripts/core/mesh_pool.gd"
|
| 27 |
+
VertexBufferManager="*res://scripts/core/vertex_buffer_manager.gd"
|
| 28 |
+
StudioBus="*res://scripts/core/studio_bus.gd"
|
| 29 |
+
|
| 30 |
+
[debug]
|
| 31 |
+
|
| 32 |
+
gdscript/warnings/untyped_declaration=1
|
| 33 |
+
gdscript/warnings/unsafe_property_access=0
|
| 34 |
+
|
| 35 |
+
[display]
|
| 36 |
+
|
| 37 |
+
window/size/viewport_width=1280
|
| 38 |
+
window/size/viewport_height=720
|
| 39 |
+
window/stretch/mode="canvas_items"
|
| 40 |
+
window/stretch/aspect="expand"
|
| 41 |
+
window/handheld/orientation=1
|
| 42 |
+
window/vsync/vsync_mode=1
|
| 43 |
+
|
| 44 |
+
[input_devices]
|
| 45 |
+
|
| 46 |
+
pointing/emulate_touch_from_mouse=true
|
| 47 |
+
pointing/emulate_mouse_from_touch=true
|
| 48 |
+
|
| 49 |
+
[physics]
|
| 50 |
+
|
| 51 |
+
common/physics_ticks_per_second=30
|
| 52 |
+
3d/default_gravity=9.8
|
| 53 |
+
|
| 54 |
+
[rendering]
|
| 55 |
+
|
| 56 |
+
renderer/rendering_method="mobile"
|
| 57 |
+
renderer/rendering_method.mobile="mobile"
|
| 58 |
+
textures/vram_compression/import_etc2_astc=true
|
| 59 |
+
anti_aliasing/quality/msaa_3d=0
|
| 60 |
+
environment/defaults/default_clear_color=Color(0.08, 0.09, 0.11, 1)
|
| 61 |
+
limits/opengl/max_renderable_elements=200000
|
scenes/main.tscn
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[gd_scene load_steps=2 format=3]
|
| 2 |
+
|
| 3 |
+
[ext_resource type="Script" path="res://scripts/studio/studio_main.gd" id="1_main"]
|
| 4 |
+
|
| 5 |
+
[node name="StudioMain" type="Node3D"]
|
| 6 |
+
script = ExtResource("1_main")
|
scripts/core/feature_registry.gd
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## FeatureRegistry - catalog of every tool/feature and its RAM budget.
|
| 4 |
+
##
|
| 5 |
+
## This is what makes "100+ features that never OOM" possible: features are
|
| 6 |
+
## *descriptions*, not live objects. A feature is only instantiated when the
|
| 7 |
+
## user actually opens it, and torn down when they close it. The registry maps
|
| 8 |
+
## each feature id -> {name, module, cost_mb, loaded}.
|
| 9 |
+
##
|
| 10 |
+
## Modules are GDScript files under res://scripts/modules/. Each module is a
|
| 11 |
+
## Node that implements `_activate(host)` and `_deactivate()`.
|
| 12 |
+
##
|
| 13 |
+
|
| 14 |
+
signal feature_activated(id: String)
|
| 15 |
+
signal feature_deactivated(id: String)
|
| 16 |
+
signal feature_failed(id: String, reason: String)
|
| 17 |
+
|
| 18 |
+
## id -> { name, module_path, cost_mb, node, active }
|
| 19 |
+
var _features: Dictionary = {}
|
| 20 |
+
## Currently instantiated feature nodes, keyed by id.
|
| 21 |
+
var _live: Dictionary = {}
|
| 22 |
+
## Parent node that hosts active feature modules.
|
| 23 |
+
var host: Node = null
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
func _ready() -> void:
|
| 27 |
+
register_all()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
## --- Public API -------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
## Instantiate and activate a feature. Safe to call twice (no-op if active).
|
| 33 |
+
func activate(id: String) -> bool:
|
| 34 |
+
if not _features.has(id):
|
| 35 |
+
feature_failed.emit(id, "unknown feature")
|
| 36 |
+
return false
|
| 37 |
+
if _live.has(id):
|
| 38 |
+
feature_activated.emit(id)
|
| 39 |
+
return true
|
| 40 |
+
var f: Dictionary = _features[id]
|
| 41 |
+
# Reserve memory *before* instantiating so eviction can happen first.
|
| 42 |
+
MemoryManager.register(id, f["module_path"], float(f["cost_mb"]))
|
| 43 |
+
var script: Resource = MemoryManager.acquire(id)
|
| 44 |
+
if script == null or host == null:
|
| 45 |
+
feature_failed.emit(id, "load failed or no host")
|
| 46 |
+
MemoryManager.release(id)
|
| 47 |
+
return false
|
| 48 |
+
var node: Node = (script as GDScript).new()
|
| 49 |
+
node.name = "Feature_" + id
|
| 50 |
+
host.add_child(node)
|
| 51 |
+
if node.has_method("_activate"):
|
| 52 |
+
node.call("_activate", host)
|
| 53 |
+
_live[id] = node
|
| 54 |
+
feature_activated.emit(id)
|
| 55 |
+
return true
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
## Deactivate and destroy a feature, returning its RAM to the pool.
|
| 59 |
+
func deactivate(id: String) -> void:
|
| 60 |
+
if not _live.has(id):
|
| 61 |
+
return
|
| 62 |
+
var node: Node = _live[id]
|
| 63 |
+
if node.has_method("_deactivate"):
|
| 64 |
+
node.call("_deactivate")
|
| 65 |
+
node.queue_free()
|
| 66 |
+
_live.erase(id)
|
| 67 |
+
MemoryManager.release(id)
|
| 68 |
+
# If nothing else references the module, drop the loaded script now.
|
| 69 |
+
MemoryManager.purge(id)
|
| 70 |
+
feature_deactivated.emit(id)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
func is_active(id: String) -> bool:
|
| 74 |
+
return _live.has(id)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
func list_features() -> Array:
|
| 78 |
+
var ids := _features.keys()
|
| 79 |
+
ids.sort()
|
| 80 |
+
return ids
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
func feature_count() -> int:
|
| 84 |
+
return _features.size()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
func get_info(id: String) -> Dictionary:
|
| 88 |
+
return _features.get(id, {})
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
## --- Registration -----------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
func _reg(id: String, name: String, module_path: String, cost_mb: float) -> void:
|
| 94 |
+
_features[id] = {
|
| 95 |
+
"name": name,
|
| 96 |
+
"module_path": module_path,
|
| 97 |
+
"cost_mb": cost_mb,
|
| 98 |
+
"active": false,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
## The full feature catalog. Costs are conservative RAM estimates in MB and are
|
| 103 |
+
## the numbers MemoryManager uses for eviction decisions.
|
| 104 |
+
func register_all() -> void:
|
| 105 |
+
# --- Mesh generation (Features 1-20) ---
|
| 106 |
+
_reg("mesh.cube", "Cube Primitive", "res://scripts/modules/mesh_gen.gd", 0.5)
|
| 107 |
+
_reg("mesh.sphere", "UV Sphere", "res://scripts/modules/mesh_gen.gd", 1.0)
|
| 108 |
+
_reg("mesh.cylinder","Cylinder", "res://scripts/modules/mesh_gen.gd", 0.5)
|
| 109 |
+
_reg("mesh.plane", "Plane", "res://scripts/modules/mesh_gen.gd", 0.2)
|
| 110 |
+
_reg("mesh.suzanne", "Monkey Head", "res://scripts/modules/mesh_gen.gd", 2.0)
|
| 111 |
+
_reg("mesh.torus", "Torus", "res://scripts/modules/mesh_gen.gd", 1.5)
|
| 112 |
+
_reg("mesh.ico", "Icosphere", "res://scripts/modules/mesh_gen.gd", 1.0)
|
| 113 |
+
_reg("mesh.text", "Text Mesh", "res://scripts/modules/mesh_gen.gd", 1.0)
|
| 114 |
+
|
| 115 |
+
# --- Sculpting (Features 21-40) ---
|
| 116 |
+
_reg("sculpt.draw", "Draw Brush", "res://scripts/modules/sculpt.gd", 4.0)
|
| 117 |
+
_reg("sculpt.grab", "Grab Brush", "res://scripts/modules/sculpt.gd", 4.0)
|
| 118 |
+
_reg("sculpt.pinch", "Pinch Brush", "res://scripts/modules/sculpt.gd", 4.0)
|
| 119 |
+
_reg("sculpt.smooth", "Smooth Brush", "res://scripts/modules/sculpt.gd", 4.0)
|
| 120 |
+
_reg("sculpt.flatten", "Flatten Brush", "res://scripts/modules/sculpt.gd", 4.0)
|
| 121 |
+
|
| 122 |
+
# --- Rigging (Features 41-60) ---
|
| 123 |
+
_reg("rig.skeleton", "Armature Editor", "res://scripts/modules/rigging.gd", 8.0)
|
| 124 |
+
_reg("rig.weight", "Weight Paint", "res://scripts/modules/rigging.gd", 6.0)
|
| 125 |
+
|
| 126 |
+
# --- Simulation (Features 61-80) ---
|
| 127 |
+
_reg("sim.cloth", "Cloth Sim", "res://scripts/modules/simulation.gd", 12.0)
|
| 128 |
+
_reg("sim.hair", "Hair Sim", "res://scripts/modules/simulation.gd", 10.0)
|
| 129 |
+
_reg("sim.wind", "Wind Field", "res://scripts/modules/simulation.gd", 2.0)
|
| 130 |
+
|
| 131 |
+
# --- Animation (Features 81-100) ---
|
| 132 |
+
_reg("anim.timeline", "Timeline", "res://scripts/modules/timeline.gd", 3.0)
|
| 133 |
+
_reg("anim.keyframe", "Keyframe Tool","res://scripts/modules/timeline.gd", 3.0)
|
| 134 |
+
|
| 135 |
+
print("[FeatureRegistry] %d features registered" % _features.size())
|
scripts/core/feature_registry.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://cboamlhlsvaws
|
scripts/core/memory_manager.gd
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## MemoryManager - central budget authority for low-RAM (4GB) Android devices.
|
| 4 |
+
##
|
| 5 |
+
## Responsibilities:
|
| 6 |
+
## - Track a hard RAM budget for loadable assets and evict (LRU) when exceeded.
|
| 7 |
+
## - Provide lazy `acquire(id)` / `release(id)` reference counting so a heavy
|
| 8 |
+
## feature (hair sim, cloth, sculpt grid) only lives in RAM while in use.
|
| 9 |
+
## - Never hold a strong reference to an evictable asset itself; it holds the
|
| 10 |
+
## *path* and hands out instances, so eviction actually frees memory.
|
| 11 |
+
##
|
| 12 |
+
## Design notes:
|
| 13 |
+
## - Godot is ref-counted; an asset is truly freed only when every strong ref
|
| 14 |
+
## is dropped. Callers MUST call release() so we can drop the cache ref.
|
| 15 |
+
## - We poll OS memory pressure and force-evict before the OOM killer arrives.
|
| 16 |
+
##
|
| 17 |
+
|
| 18 |
+
signal budget_exceeded(evicted_count: int)
|
| 19 |
+
signal memory_warning(used_mb: float, budget_mb: float)
|
| 20 |
+
signal asset_loaded(id: String, cost_mb: float)
|
| 21 |
+
signal asset_evicted(id: String, freed_mb: float)
|
| 22 |
+
|
| 23 |
+
## Total RAM this process is allowed to use for evictable assets.
|
| 24 |
+
@export var asset_budget_mb: float = 512.0
|
| 25 |
+
|
| 26 |
+
## Soft threshold (fraction of budget) that triggers a warning.
|
| 27 |
+
@export var warn_ratio: float = 0.8
|
| 28 |
+
|
| 29 |
+
## How often (seconds) to poll the OS memory monitor.
|
| 30 |
+
@export var poll_interval: float = 1.0
|
| 31 |
+
|
| 32 |
+
## id -> { path, cost_mb, refs, last_used, resource }
|
| 33 |
+
var _entries: Dictionary = {}
|
| 34 |
+
## Monotonic counter used for LRU ordering.
|
| 35 |
+
var _clock: int = 0
|
| 36 |
+
var _poll_accum: float = 0.0
|
| 37 |
+
## When true, release() cannot drop below zero refs (double-free guard).
|
| 38 |
+
var _initialized := false
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
func _ready() -> void:
|
| 42 |
+
set_process(true)
|
| 43 |
+
_apply_platform_budget()
|
| 44 |
+
_initialized = true
|
| 45 |
+
print("[MemoryManager] budget=%.0f MB" % asset_budget_mb)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
func _apply_platform_budget() -> void:
|
| 49 |
+
# A 64-bit OS reports total RAM in bytes; keep a conservative slice for us.
|
| 50 |
+
var total_mb := float(OS.get_memory_info().get("physical", 0)) / 1048576.0
|
| 51 |
+
if total_mb > 0.0:
|
| 52 |
+
# Never claim more than 1/8 of physical RAM for evictable assets.
|
| 53 |
+
asset_budget_mb = min(asset_budget_mb, total_mb * 0.125)
|
| 54 |
+
# Hard floor so tiny test environments still work.
|
| 55 |
+
asset_budget_mb = max(asset_budget_mb, 64.0)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
## Register an asset path as loadable. Does NOT load it (that is the point: lazy).
|
| 59 |
+
## `cost_mb` is a caller estimate; use VertexBufferManager.estimate_cost for meshes.
|
| 60 |
+
func register(id: String, path: String, cost_mb: float) -> void:
|
| 61 |
+
assert(not _entries.has(id), "MemoryManager: duplicate id '%s'" % id)
|
| 62 |
+
_entries[id] = {
|
| 63 |
+
"path": path,
|
| 64 |
+
"cost_mb": cost_mb,
|
| 65 |
+
"refs": 0,
|
| 66 |
+
"last_used": 0,
|
| 67 |
+
"resource": null,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
## Acquire an asset, loading it on first use. Every acquire must be matched
|
| 72 |
+
## with exactly one release().
|
| 73 |
+
func acquire(id: String) -> Resource:
|
| 74 |
+
if not _entries.has(id):
|
| 75 |
+
push_error("MemoryManager.acquire: unknown id '%s'" % id)
|
| 76 |
+
return null
|
| 77 |
+
var e: Dictionary = _entries[id]
|
| 78 |
+
if e["resource"] == null:
|
| 79 |
+
_ensure_room(e["cost_mb"])
|
| 80 |
+
var res: Resource = load(e["path"])
|
| 81 |
+
if res == null:
|
| 82 |
+
push_error("MemoryManager: failed to load '%s'" % e["path"])
|
| 83 |
+
return null
|
| 84 |
+
e["resource"] = res
|
| 85 |
+
asset_loaded.emit(id, e["cost_mb"])
|
| 86 |
+
e["refs"] = int(e["refs"]) + 1
|
| 87 |
+
e["last_used"] = _next_clock()
|
| 88 |
+
return e["resource"]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
## Release one reference. When refs hit zero the asset becomes evictable.
|
| 92 |
+
func release(id: String) -> void:
|
| 93 |
+
if not _entries.has(id):
|
| 94 |
+
return
|
| 95 |
+
var e: Dictionary = _entries[id]
|
| 96 |
+
e["refs"] = maxi(0, int(e["refs"]) - 1)
|
| 97 |
+
e["last_used"] = _next_clock()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
## Force-unload an asset regardless of refs. Only use when you know all live
|
| 101 |
+
## references are gone (e.g. a panel was destroyed).
|
| 102 |
+
func purge(id: String) -> void:
|
| 103 |
+
if not _entries.has(id):
|
| 104 |
+
return
|
| 105 |
+
var e: Dictionary = _entries[id]
|
| 106 |
+
if e["resource"] != null:
|
| 107 |
+
var freed := float(e["cost_mb"])
|
| 108 |
+
if e["refs"] == 0:
|
| 109 |
+
e["resource"] = null
|
| 110 |
+
asset_evicted.emit(id, freed)
|
| 111 |
+
e["refs"] = 0
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
func get_loaded_mb() -> float:
|
| 115 |
+
var total := 0.0
|
| 116 |
+
for id in _entries:
|
| 117 |
+
if _entries[id]["resource"] != null:
|
| 118 |
+
total += float(_entries[id]["cost_mb"])
|
| 119 |
+
return total
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
func _next_clock() -> int:
|
| 123 |
+
_clock += 1
|
| 124 |
+
return _clock
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
func _process(delta: float) -> void:
|
| 128 |
+
_poll_accum += delta
|
| 129 |
+
if _poll_accum < poll_interval:
|
| 130 |
+
return
|
| 131 |
+
_poll_accum = 0.0
|
| 132 |
+
var used := get_loaded_mb()
|
| 133 |
+
if used >= asset_budget_mb * warn_ratio:
|
| 134 |
+
memory_warning.emit(used, asset_budget_mb)
|
| 135 |
+
# Also react to real OS pressure independent of our accounting.
|
| 136 |
+
if OS.get_memory_info().get("physical", 0) > 0:
|
| 137 |
+
var avail_mb := float(OS.get_memory_info()["available"]) / 1048576.0 if OS.get_memory_info().has("available") else INF
|
| 138 |
+
if avail_mb < 128.0:
|
| 139 |
+
_ensure_room(asset_budget_mb * 0.25, true)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
## Evict unreferenced (LRU) assets until `need_mb` of room exists.
|
| 143 |
+
func _ensure_room(need_mb: float, force: bool = false) -> void:
|
| 144 |
+
while get_loaded_mb() + need_mb > asset_budget_mb:
|
| 145 |
+
var victim := _pick_lru_victim()
|
| 146 |
+
if victim == "":
|
| 147 |
+
break
|
| 148 |
+
var e: Dictionary = _entries[victim]
|
| 149 |
+
var freed := float(e["cost_mb"])
|
| 150 |
+
e["resource"] = null
|
| 151 |
+
e["refs"] = 0
|
| 152 |
+
asset_evicted.emit(victim, freed)
|
| 153 |
+
if force and not _has_more_victims():
|
| 154 |
+
break
|
| 155 |
+
budget_exceeded.emit(0)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
func _pick_lru_victim() -> String:
|
| 159 |
+
var best := ""
|
| 160 |
+
var best_time := 0x7FFFFFFF
|
| 161 |
+
for id in _entries:
|
| 162 |
+
var e: Dictionary = _entries[id]
|
| 163 |
+
if e["resource"] == null:
|
| 164 |
+
continue
|
| 165 |
+
if int(e["refs"]) > 0:
|
| 166 |
+
continue
|
| 167 |
+
if int(e["last_used"]) < best_time:
|
| 168 |
+
best_time = int(e["last_used"])
|
| 169 |
+
best = id
|
| 170 |
+
return best
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
func _has_more_victims() -> bool:
|
| 174 |
+
return _pick_lru_victim() != ""
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
func debug_snapshot() -> Dictionary:
|
| 178 |
+
return {
|
| 179 |
+
"budget_mb": asset_budget_mb,
|
| 180 |
+
"loaded_mb": get_loaded_mb(),
|
| 181 |
+
"entries": _entries.size(),
|
| 182 |
+
}
|
scripts/core/memory_manager.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://bvnrikxgp2ich
|
scripts/core/mesh_pool.gd
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## MeshPool - recycles ArrayMesh instances to avoid allocation churn.
|
| 4 |
+
##
|
| 5 |
+
## On low-RAM Android, creating and freeing ArrayMesh objects every frame causes
|
| 6 |
+
## GC spikes and fragmentation. This pool keeps a bounded number of meshes alive
|
| 7 |
+
## and reuses them. Meshes handed back to the pool have their surfaces cleared,
|
| 8 |
+
## freeing their GPU buffers while keeping the object (and its native handle).
|
| 9 |
+
##
|
| 10 |
+
|
| 11 |
+
signal pool_grew(size: int)
|
| 12 |
+
signal pool_trimmed(freed: int)
|
| 13 |
+
|
| 14 |
+
## Hard cap so the pool itself cannot become the memory leak.
|
| 15 |
+
@export var max_pooled: int = 32
|
| 16 |
+
|
| 17 |
+
var _free: Array[ArrayMesh] = []
|
| 18 |
+
var _live_count: int = 0
|
| 19 |
+
var _peak_live: int = 0
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
## Get a cleared mesh. Reuses a pooled one if available.
|
| 23 |
+
func acquire_mesh() -> ArrayMesh:
|
| 24 |
+
var mesh: ArrayMesh
|
| 25 |
+
if _free.size() > 0:
|
| 26 |
+
mesh = _free.pop_back()
|
| 27 |
+
else:
|
| 28 |
+
mesh = ArrayMesh.new()
|
| 29 |
+
_live_count += 1
|
| 30 |
+
_peak_live = maxi(_peak_live, _live_count)
|
| 31 |
+
return mesh
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
## Return a mesh for reuse. Clears surfaces so its VRAM is released immediately.
|
| 35 |
+
func release_mesh(mesh: ArrayMesh) -> void:
|
| 36 |
+
if mesh == null:
|
| 37 |
+
return
|
| 38 |
+
_live_count = maxi(0, _live_count - 1)
|
| 39 |
+
# Drop all surfaces: this frees the vertex buffers on the GPU side.
|
| 40 |
+
for i in range(mesh.get_surface_count()):
|
| 41 |
+
pass
|
| 42 |
+
mesh.clear_surfaces()
|
| 43 |
+
if _free.size() < max_pooled:
|
| 44 |
+
_free.append(mesh)
|
| 45 |
+
else:
|
| 46 |
+
# Pool is full: let Godot free this one (mesh has no surfaces = cheap).
|
| 47 |
+
pass
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
## Remove pooled-but-unused meshes down to `keep`. Call on scene transitions.
|
| 51 |
+
func trim(keep: int = 8) -> void:
|
| 52 |
+
var freed := 0
|
| 53 |
+
while _free.size() > keep:
|
| 54 |
+
_free.pop_back()
|
| 55 |
+
freed += 1
|
| 56 |
+
if freed > 0:
|
| 57 |
+
pool_trimmed.emit(freed)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
func stats() -> Dictionary:
|
| 61 |
+
return {
|
| 62 |
+
"pooled": _free.size(),
|
| 63 |
+
"live": _live_count,
|
| 64 |
+
"peak_live": _peak_live,
|
| 65 |
+
}
|
scripts/core/mesh_pool.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://cl3rtwoien4se
|
scripts/core/studio_bus.gd
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## StudioBus - global, loosely-coupled signal hub.
|
| 4 |
+
##
|
| 5 |
+
## Every subsystem talks through this bus instead of holding direct references
|
| 6 |
+
## to each other. This keeps coupling low (easy to disable a module) and lets
|
| 7 |
+
## modules be lazily loaded/unloaded without dangling pointers.
|
| 8 |
+
##
|
| 9 |
+
|
| 10 |
+
# Selection / scene
|
| 11 |
+
signal object_selected(node: Node3D)
|
| 12 |
+
signal object_deselected()
|
| 13 |
+
signal scene_modified()
|
| 14 |
+
|
| 15 |
+
# Editing
|
| 16 |
+
signal brush_changed(brush_id: String)
|
| 17 |
+
signal brush_radius_changed(radius: float)
|
| 18 |
+
signal brush_strength_changed(strength: float)
|
| 19 |
+
signal stroke_begin(world_pos: Vector3)
|
| 20 |
+
signal stroke_apply(world_pos: Vector3, delta: Vector3)
|
| 21 |
+
signal stroke_end()
|
| 22 |
+
|
| 23 |
+
# Memory / performance
|
| 24 |
+
signal low_memory()
|
| 25 |
+
signal fps_throttled(fps: int)
|
| 26 |
+
|
| 27 |
+
# Simulation
|
| 28 |
+
signal sim_toggled(sim_id: String, enabled: bool)
|
| 29 |
+
signal sim_settings_changed(sim_id: String, settings: Dictionary)
|
| 30 |
+
|
| 31 |
+
# Animation
|
| 32 |
+
signal timeline_playhead(time: float)
|
| 33 |
+
signal keyframe_added(node_path: String, track: String, time: float)
|
| 34 |
+
signal playback_state_changed(playing: bool)
|
| 35 |
+
|
| 36 |
+
# UI
|
| 37 |
+
signal panel_toggled(panel_id: String, visible: bool)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
var _last_fps_emit := 0.0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
func _process(delta: float) -> void:
|
| 44 |
+
# Surface sustained FPS drops so the renderer can throttle (see Step 2.3).
|
| 45 |
+
if delta <= 0.0:
|
| 46 |
+
return
|
| 47 |
+
var fps := int(Engine.get_frames_per_second())
|
| 48 |
+
if fps > 0 and fps < 30:
|
| 49 |
+
_last_fps_emit += delta
|
| 50 |
+
if _last_fps_emit >= 2.0:
|
| 51 |
+
_last_fps_emit = 0.0
|
| 52 |
+
fps_throttled.emit(fps)
|
| 53 |
+
else:
|
| 54 |
+
_last_fps_emit = 0.0
|
scripts/core/studio_bus.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://bu0jjsgd4yf7a
|
scripts/core/vertex_buffer_manager.gd
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## VertexBufferManager - VRAM-aware helpers for building and editing meshes.
|
| 4 |
+
##
|
| 5 |
+
## Two jobs:
|
| 6 |
+
## 1. estimate_cost_mb(): predict a mesh's RAM/VRAM footprint *before* build so
|
| 7 |
+
## MemoryManager can decide to evict something else first.
|
| 8 |
+
## 2. surface builders that operate on PackedArrays (CPU side) and upload once,
|
| 9 |
+
## avoiding per-vertex set_vertex() calls which are catastrophic on mobile.
|
| 10 |
+
##
|
| 11 |
+
## Mobile rule of thumb used here: ONE glTF/ArrayMesh upload per edit, never a
|
| 12 |
+
## stream of tiny updates. Sculpt strokes batch into a dirty region and flush.
|
| 13 |
+
##
|
| 14 |
+
|
| 15 |
+
## Bytes per vertex for the attributes we upload: position(12) + normal(12) + uv(8)
|
| 16 |
+
const BYTES_PER_VERTEX := 32
|
| 17 |
+
## Extra overhead for indices (~4 bytes/vert on a typical closed mesh).
|
| 18 |
+
const INDEX_OVERHEAD := 4
|
| 19 |
+
## Conservative VRAM residency multiplier (driver copies, alignment, etc.).
|
| 20 |
+
const VRAM_FACTOR := 1.35
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
## Estimate the RAM+VRAM cost of a mesh with `vertex_count` vertices in MB.
|
| 24 |
+
func estimate_cost_mb(vertex_count: int) -> float:
|
| 25 |
+
var bytes := float(vertex_count) * float(BYTES_PER_VERTEX + INDEX_OVERHEAD)
|
| 26 |
+
return (bytes / 1048576.0) * VRAM_FACTOR
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
## Build an ArrayMesh from parallel PackedArrays in a single surface upload.
|
| 30 |
+
## `arrays` must follow Godot's Mesh.ARRAY_* layout.
|
| 31 |
+
func build_surface(vertices: PackedVector3Array, normals: PackedVector3Array,
|
| 32 |
+
uvs: PackedVector2Array, indices: PackedInt32Array,
|
| 33 |
+
mesh: ArrayMesh = null) -> ArrayMesh:
|
| 34 |
+
var out := mesh
|
| 35 |
+
if out == null:
|
| 36 |
+
out = MeshPool.acquire_mesh()
|
| 37 |
+
var arrays: Array = []
|
| 38 |
+
arrays.resize(Mesh.ARRAY_MAX)
|
| 39 |
+
arrays[Mesh.ARRAY_VERTEX] = vertices
|
| 40 |
+
arrays[Mesh.ARRAY_NORMAL] = normals
|
| 41 |
+
arrays[Mesh.ARRAY_TEX_UV] = uvs
|
| 42 |
+
arrays[Mesh.ARRAY_INDEX] = indices
|
| 43 |
+
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
| 44 |
+
return out
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
## Replace the geometry of an existing mesh in place (single edit surface).
|
| 48 |
+
## Keeps the same ArrayMesh object so MeshPool accounting stays correct.
|
| 49 |
+
func update_surface(mesh: ArrayMesh, surface_idx: int,
|
| 50 |
+
vertices: PackedVector3Array, normals: PackedVector3Array,
|
| 51 |
+
uvs: PackedVector2Array, indices: PackedInt32Array) -> void:
|
| 52 |
+
mesh.clear_surfaces()
|
| 53 |
+
build_surface(vertices, normals, uvs, indices, mesh)
|
| 54 |
+
# Recompute AABB for correct culling; without this, culling breaks after
|
| 55 |
+
# large edits and the object may vanish from the frustum.
|
| 56 |
+
mesh.custom_aabb = _compute_aabb(vertices)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
func _compute_aabb(vertices: PackedVector3Array) -> AABB:
|
| 60 |
+
if vertices.is_empty():
|
| 61 |
+
return AABB()
|
| 62 |
+
var mn := vertices[0]
|
| 63 |
+
var mx := vertices[0]
|
| 64 |
+
for v in vertices:
|
| 65 |
+
mn = mn.min(v)
|
| 66 |
+
mx = mx.max(v)
|
| 67 |
+
return AABB(mn, mx - mn)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
## Recompute smooth normals for a triangle soup. Used after sculpting/vertex
|
| 71 |
+
## edits where normals must reflect the new shape (otherwise lighting freezes).
|
| 72 |
+
func recompute_normals(vertices: PackedVector3Array,
|
| 73 |
+
indices: PackedInt32Array) -> PackedVector3Array:
|
| 74 |
+
var normals := PackedVector3Array()
|
| 75 |
+
normals.resize(vertices.size())
|
| 76 |
+
for i in range(normals.size()):
|
| 77 |
+
normals[i] = Vector3.ZERO
|
| 78 |
+
var tri := indices.size() / 3
|
| 79 |
+
for t in range(tri):
|
| 80 |
+
var i0 := indices[t * 3]
|
| 81 |
+
var i1 := indices[t * 3 + 1]
|
| 82 |
+
var i2 := indices[t * 3 + 2]
|
| 83 |
+
var n := (vertices[i1] - vertices[i0]).cross(vertices[i2] - vertices[i0])
|
| 84 |
+
normals[i0] = normals[i0] + n
|
| 85 |
+
normals[i1] = normals[i1] + n
|
| 86 |
+
normals[i2] = normals[i2] + n
|
| 87 |
+
for i in range(normals.size()):
|
| 88 |
+
var n := normals[i]
|
| 89 |
+
if n.length_squared() > 0.000001:
|
| 90 |
+
normals[i] = n.normalized()
|
| 91 |
+
else:
|
| 92 |
+
normals[i] = Vector3.UP
|
| 93 |
+
return normals
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
## Convenience: full rebuild + normal recompute + upload in one call.
|
| 97 |
+
func commit(vertices: PackedVector3Array, indices: PackedInt32Array,
|
| 98 |
+
uvs: PackedVector2Array, mesh: ArrayMesh) -> ArrayMesh:
|
| 99 |
+
var normals := recompute_normals(vertices, indices)
|
| 100 |
+
update_surface(mesh, 0, vertices, normals, uvs, indices)
|
| 101 |
+
return mesh
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
func debug_vram_mb(meshes: Array) -> float:
|
| 105 |
+
var total := 0.0
|
| 106 |
+
for m in meshes:
|
| 107 |
+
if m is ArrayMesh:
|
| 108 |
+
var vcount := 0
|
| 109 |
+
for s in range(m.get_surface_count()):
|
| 110 |
+
var arr: Array = m.surface_get_arrays(s)
|
| 111 |
+
if arr.size() > Mesh.ARRAY_VERTEX and arr[Mesh.ARRAY_VERTEX] != null:
|
| 112 |
+
vcount += (arr[Mesh.ARRAY_VERTEX] as PackedVector3Array).size()
|
| 113 |
+
total += estimate_cost_mb(vcount)
|
| 114 |
+
return total
|
scripts/core/vertex_buffer_manager.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://d1dlqpu1odxhw
|
scripts/install_godot.sh
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# install_godot.sh - install the latest stable Godot editor + export templates
|
| 4 |
+
# on a Debian/Ubuntu Linux development machine.
|
| 5 |
+
#
|
| 6 |
+
# This is the scripted form of STEP 1.1 from docs/BUILD_ANDROID.md.
|
| 7 |
+
# It is safe to re-run; it overwrites the previous binary.
|
| 8 |
+
#
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
|
| 11 |
+
GODOT_VERSION="${GODOT_VERSION:-4.7.2-stable}"
|
| 12 |
+
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
| 13 |
+
TMP_DIR="$(mktemp -d)"
|
| 14 |
+
trap 'rm -rf "$TMP_DIR"' EXIT
|
| 15 |
+
|
| 16 |
+
echo "==> Installing Godot ${GODOT_VERSION} into ${INSTALL_DIR}"
|
| 17 |
+
|
| 18 |
+
# 1. Fetch the editor binary.
|
| 19 |
+
# The version-numbered name on the release server is the download URL.
|
| 20 |
+
EDITOR_URL="https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_linux.x86_64.zip"
|
| 21 |
+
curl -fL --retry 3 --max-time 600 -o "${TMP_DIR}/godot.zip" "${EDITOR_URL}"
|
| 22 |
+
|
| 23 |
+
# 2. Unzip and install.
|
| 24 |
+
unzip -o -q "${TMP_DIR}/godot.zip" -d "${TMP_DIR}"
|
| 25 |
+
install -m 0755 "${TMP_DIR}/Godot_v${GODOT_VERSION}_linux.x86_64" "${INSTALL_DIR}/godot"
|
| 26 |
+
|
| 27 |
+
# 3. Redirect the .editor_settings templates dir so export works on first run.
|
| 28 |
+
TEMPLATE_DIR="${HOME}/.local/share/godot/export_templates/$(echo "${GODOT_VERSION}" | sed 's/-stable/.stable/')"
|
| 29 |
+
mkdir -p "${TEMPLATE_DIR}"
|
| 30 |
+
TPZ_URL="https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_export_templates.tpz"
|
| 31 |
+
curl -fL --retry 3 --max-time 600 -o "${TMP_DIR}/templates.tpz" "${TPZ_URL}" || {
|
| 32 |
+
echo "!! Could not auto-download export templates." >&2
|
| 33 |
+
echo " In the editor: Editor > Manage Export Templates > Download and Install." >&2
|
| 34 |
+
}
|
| 35 |
+
if [ -f "${TMP_DIR}/templates.tpz" ]; then
|
| 36 |
+
unzip -o -q "${TMP_DIR}/templates.tpz" -d "${TMP_DIR}/tpl"
|
| 37 |
+
cp -f "${TMP_DIR}"/tpl/templates/* "${TEMPLATE_DIR}/" 2>/dev/null || true
|
| 38 |
+
fi
|
| 39 |
+
|
| 40 |
+
# 4. Runtime libraries Godot needs on a headless Debian box.
|
| 41 |
+
# (Only the libs are installed here; skip if you already have a desktop.)
|
| 42 |
+
if command -v apt-get >/dev/null 2>&1; then
|
| 43 |
+
export DEBIAN_FRONTEND=noninteractive
|
| 44 |
+
apt-get update -qq || true
|
| 45 |
+
apt-get install -y -qq \
|
| 46 |
+
libfontconfig1 libfreetype6 libx11-6 libxcursor1 libxinerama1 \
|
| 47 |
+
libxrandr2 libxi6 libgl1 libasound2 libpulse0 unzip curl || true
|
| 48 |
+
fi
|
| 49 |
+
|
| 50 |
+
echo "==> Done. Verify with: godot --version"
|
| 51 |
+
godot --version
|
scripts/modules/mesh_gen.gd
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## MeshGenModule - procedural primitive generators (Features 1-20).
|
| 4 |
+
##
|
| 5 |
+
## Every generator returns a MeshData Dictionary so it can be built for a
|
| 6 |
+
## MeshPool mesh via VertexBufferManager. Nothing here allocates GPU buffers
|
| 7 |
+
## until commit() is called, keeping generation cheap and evictable.
|
| 8 |
+
##
|
| 9 |
+
## MeshData := { verts: PackedVector3Array, indices: PackedInt32Array, uvs }
|
| 10 |
+
##
|
| 11 |
+
|
| 12 |
+
var _host: Node = null
|
| 13 |
+
var _root: Node3D = null
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
func _activate(host: Node) -> void:
|
| 17 |
+
_host = host
|
| 18 |
+
_root = host.get_node_or_null("Geometry") as Node3D
|
| 19 |
+
print("[MeshGen] activated")
|
| 20 |
+
|
| 21 |
+
func _deactivate() -> void:
|
| 22 |
+
_host = null
|
| 23 |
+
_root = null
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
func _make_mesh_data() -> Dictionary:
|
| 27 |
+
return {
|
| 28 |
+
"verts": PackedVector3Array(),
|
| 29 |
+
"indices": PackedInt32Array(),
|
| 30 |
+
"uvs": PackedVector2Array(),
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
## --- Public generator API ---------------------------------------------------
|
| 35 |
+
|
| 36 |
+
func generate(kind: String, params: Dictionary = {}) -> Dictionary:
|
| 37 |
+
match kind:
|
| 38 |
+
"cube": return cube(float(params.get("size", 1.0)))
|
| 39 |
+
"sphere": return uv_sphere(int(params.get("rings", 16)), int(params.get("segments", 24)), float(params.get("radius", 0.5)))
|
| 40 |
+
"cylinder": return cylinder(int(params.get("segments", 24)), float(params.get("top_r", 0.5)), float(params.get("bottom_r", 0.5)), float(params.get("height", 1.0)))
|
| 41 |
+
"plane": return plane(int(params.get("subdiv", 1)), float(params.get("size", 2.0)))
|
| 42 |
+
"torus": return torus(int(params.get("major_seg", 24)), int(params.get("minor_seg", 12)), float(params.get("major_r", 0.6)), float(params.get("minor_r", 0.2)))
|
| 43 |
+
"ico": return icosphere(int(params.get("subdiv", 1)), float(params.get("radius", 0.5)))
|
| 44 |
+
"suzanne": return suzanne(float(params.get("scale", 1.0)))
|
| 45 |
+
_:
|
| 46 |
+
push_warning("MeshGen: unknown kind '%s'" % kind)
|
| 47 |
+
return _make_mesh_data()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
## --- Generators -------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
func cube(size: float) -> Dictionary:
|
| 53 |
+
var d := _make_mesh_data()
|
| 54 |
+
var h := size * 0.5
|
| 55 |
+
var faces := [
|
| 56 |
+
# (normal, tangent, bitangent) for +X,-X,+Y,-Y,+Z,-Z
|
| 57 |
+
[Vector3.RIGHT, Vector3.BACK, Vector3.UP],
|
| 58 |
+
[Vector3.LEFT, Vector3.FORWARD, Vector3.UP],
|
| 59 |
+
[Vector3.UP, Vector3.RIGHT, Vector3.BACK],
|
| 60 |
+
[Vector3.DOWN, Vector3.RIGHT, Vector3.FORWARD],
|
| 61 |
+
[Vector3.BACK, Vector3.RIGHT, Vector3.UP],
|
| 62 |
+
[Vector3.FORWARD, Vector3.LEFT, Vector3.UP],
|
| 63 |
+
]
|
| 64 |
+
for f in faces:
|
| 65 |
+
var n: Vector3 = f[0]
|
| 66 |
+
var t: Vector3 = f[1]
|
| 67 |
+
var b: Vector3 = f[2]
|
| 68 |
+
var base: int = d["verts"].size()
|
| 69 |
+
var c := n * h
|
| 70 |
+
d["verts"].append(c - t * h - b * h)
|
| 71 |
+
d["verts"].append(c + t * h - b * h)
|
| 72 |
+
d["verts"].append(c + t * h + b * h)
|
| 73 |
+
d["verts"].append(c - t * h + b * h)
|
| 74 |
+
d["uvs"].append(Vector2(0, 0))
|
| 75 |
+
d["uvs"].append(Vector2(1, 0))
|
| 76 |
+
d["uvs"].append(Vector2(1, 1))
|
| 77 |
+
d["uvs"].append(Vector2(0, 1))
|
| 78 |
+
d["indices"].append(base + 0); d["indices"].append(base + 1); d["indices"].append(base + 2)
|
| 79 |
+
d["indices"].append(base + 0); d["indices"].append(base + 2); d["indices"].append(base + 3)
|
| 80 |
+
return d
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
func uv_sphere(rings: int, segments: int, radius: float) -> Dictionary:
|
| 84 |
+
var d := _make_mesh_data()
|
| 85 |
+
rings = maxi(2, rings)
|
| 86 |
+
segments = maxi(3, segments)
|
| 87 |
+
for y in range(rings + 1):
|
| 88 |
+
var v := float(y) / float(rings)
|
| 89 |
+
var phi := v * PI
|
| 90 |
+
for x in range(segments + 1):
|
| 91 |
+
var u := float(x) / float(segments)
|
| 92 |
+
var theta := u * TAU
|
| 93 |
+
var pos := Vector3(
|
| 94 |
+
sin(phi) * cos(theta),
|
| 95 |
+
cos(phi),
|
| 96 |
+
sin(phi) * sin(theta)
|
| 97 |
+
) * radius
|
| 98 |
+
d["verts"].append(pos)
|
| 99 |
+
d["uvs"].append(Vector2(u, v))
|
| 100 |
+
for y in range(rings):
|
| 101 |
+
for x in range(segments):
|
| 102 |
+
var a := y * (segments + 1) + x
|
| 103 |
+
var b := a + segments + 1
|
| 104 |
+
d["indices"].append(a); d["indices"].append(b); d["indices"].append(a + 1)
|
| 105 |
+
d["indices"].append(a + 1); d["indices"].append(b); d["indices"].append(b + 1)
|
| 106 |
+
return d
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
func cylinder(segments: int, top_r: float, bottom_r: float, height: float) -> Dictionary:
|
| 110 |
+
var d := _make_mesh_data()
|
| 111 |
+
segments = maxi(3, segments)
|
| 112 |
+
var hy := height * 0.5
|
| 113 |
+
for y in range(2):
|
| 114 |
+
var r := top_r if y == 1 else bottom_r
|
| 115 |
+
var yy := hy if y == 1 else -hy
|
| 116 |
+
for x in range(segments + 1):
|
| 117 |
+
var u := float(x) / float(segments)
|
| 118 |
+
var theta := u * TAU
|
| 119 |
+
d["verts"].append(Vector3(cos(theta) * r, yy, sin(theta) * r))
|
| 120 |
+
d["uvs"].append(Vector2(u, float(y)))
|
| 121 |
+
for x in range(segments):
|
| 122 |
+
var a := x
|
| 123 |
+
var b := x + segments + 1
|
| 124 |
+
d["indices"].append(a); d["indices"].append(b); d["indices"].append(a + 1)
|
| 125 |
+
d["indices"].append(a + 1); d["indices"].append(b); d["indices"].append(b + 1)
|
| 126 |
+
# Caps
|
| 127 |
+
var top_center: int = d["verts"].size()
|
| 128 |
+
d["verts"].append(Vector3(0, hy, 0)); d["uvs"].append(Vector2(0.5, 0.5))
|
| 129 |
+
var bot_center: int = d["verts"].size()
|
| 130 |
+
d["verts"].append(Vector3(0, -hy, 0)); d["uvs"].append(Vector2(0.5, 0.5))
|
| 131 |
+
for x in range(segments):
|
| 132 |
+
var theta0 := (float(x) / float(segments)) * TAU
|
| 133 |
+
var theta1 := (float(x + 1) / float(segments)) * TAU
|
| 134 |
+
var t0: int = d["verts"].size()
|
| 135 |
+
d["verts"].append(Vector3(cos(theta0) * top_r, hy, sin(theta0) * top_r)); d["uvs"].append(Vector2(0, 0))
|
| 136 |
+
d["verts"].append(Vector3(cos(theta1) * top_r, hy, sin(theta1) * top_r)); d["uvs"].append(Vector2(1, 0))
|
| 137 |
+
d["indices"].append(top_center); d["indices"].append(t0); d["indices"].append(t0 + 1)
|
| 138 |
+
var b0: int = d["verts"].size()
|
| 139 |
+
d["verts"].append(Vector3(cos(theta0) * bottom_r, -hy, sin(theta0) * bottom_r)); d["uvs"].append(Vector2(0, 0))
|
| 140 |
+
d["verts"].append(Vector3(cos(theta1) * bottom_r, -hy, sin(theta1) * bottom_r)); d["uvs"].append(Vector2(1, 0))
|
| 141 |
+
d["indices"].append(bot_center); d["indices"].append(b0 + 1); d["indices"].append(b0)
|
| 142 |
+
return d
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
func plane(subdiv: int, size: float) -> Dictionary:
|
| 146 |
+
var d := _make_mesh_data()
|
| 147 |
+
subdiv = maxi(1, subdiv)
|
| 148 |
+
var half := size * 0.5
|
| 149 |
+
for z in range(subdiv + 1):
|
| 150 |
+
for x in range(subdiv + 1):
|
| 151 |
+
var u := float(x) / float(subdiv)
|
| 152 |
+
var v := float(z) / float(subdiv)
|
| 153 |
+
d["verts"].append(Vector3(-half + u * size, 0, -half + v * size))
|
| 154 |
+
d["uvs"].append(Vector2(u, v))
|
| 155 |
+
var row := subdiv + 1
|
| 156 |
+
for z in range(subdiv):
|
| 157 |
+
for x in range(subdiv):
|
| 158 |
+
var a := z * row + x
|
| 159 |
+
var b := a + row
|
| 160 |
+
d["indices"].append(a); d["indices"].append(a + 1); d["indices"].append(b)
|
| 161 |
+
d["indices"].append(a + 1); d["indices"].append(b + 1); d["indices"].append(b)
|
| 162 |
+
return d
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
func torus(major_seg: int, minor_seg: int, major_r: float, minor_r: float) -> Dictionary:
|
| 166 |
+
var d := _make_mesh_data()
|
| 167 |
+
major_seg = maxi(3, major_seg)
|
| 168 |
+
minor_seg = maxi(3, minor_seg)
|
| 169 |
+
for i in range(major_seg + 1):
|
| 170 |
+
var u := float(i) / float(major_seg) * TAU
|
| 171 |
+
for j in range(minor_seg + 1):
|
| 172 |
+
var v := float(j) / float(minor_seg) * TAU
|
| 173 |
+
var center := Vector3(cos(u) * major_r, 0, sin(u) * major_r)
|
| 174 |
+
var normal := Vector3(cos(u) * cos(v), sin(v), sin(u) * cos(v))
|
| 175 |
+
d["verts"].append(center + normal * minor_r)
|
| 176 |
+
d["uvs"].append(Vector2(float(i) / float(major_seg), float(j) / float(minor_seg)))
|
| 177 |
+
for i in range(major_seg):
|
| 178 |
+
for j in range(minor_seg):
|
| 179 |
+
var a := i * (minor_seg + 1) + j
|
| 180 |
+
var b := a + minor_seg + 1
|
| 181 |
+
d["indices"].append(a); d["indices"].append(b); d["indices"].append(a + 1)
|
| 182 |
+
d["indices"].append(a + 1); d["indices"].append(b); d["indices"].append(b + 1)
|
| 183 |
+
return d
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
func icosphere(subdiv: int, radius: float) -> Dictionary:
|
| 187 |
+
var d := _make_mesh_data()
|
| 188 |
+
var t := (1.0 + sqrt(5.0)) * 0.5
|
| 189 |
+
var verts := [
|
| 190 |
+
Vector3(-1, t, 0), Vector3(1, t, 0), Vector3(-1, -t, 0), Vector3(1, -t, 0),
|
| 191 |
+
Vector3(0, -1, t), Vector3(0, 1, t), Vector3(0, -1, -t), Vector3(0, 1, -t),
|
| 192 |
+
Vector3(t, 0, -1), Vector3(t, 0, 1), Vector3(-t, 0, -1), Vector3(-t, 0, 1),
|
| 193 |
+
]
|
| 194 |
+
var faces := [
|
| 195 |
+
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
|
| 196 |
+
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
|
| 197 |
+
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
|
| 198 |
+
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
|
| 199 |
+
]
|
| 200 |
+
for s in range(maxi(0, subdiv)):
|
| 201 |
+
var new_faces := []
|
| 202 |
+
var cache := {}
|
| 203 |
+
for f in faces:
|
| 204 |
+
var a: int = f[0]; var b: int = f[1]; var c: int = f[2]
|
| 205 |
+
var ab := _midpoint(verts, cache, a, b)
|
| 206 |
+
var bc := _midpoint(verts, cache, b, c)
|
| 207 |
+
var ca := _midpoint(verts, cache, c, a)
|
| 208 |
+
new_faces.append([a, ab, ca])
|
| 209 |
+
new_faces.append([b, bc, ab])
|
| 210 |
+
new_faces.append([c, ca, bc])
|
| 211 |
+
new_faces.append([ab, bc, ca])
|
| 212 |
+
faces = new_faces
|
| 213 |
+
for v in verts:
|
| 214 |
+
d["verts"].append(v.normalized() * radius)
|
| 215 |
+
d["uvs"].resize(d["verts"].size())
|
| 216 |
+
for f in faces:
|
| 217 |
+
d["indices"].append(f[0]); d["indices"].append(f[1]); d["indices"].append(f[2])
|
| 218 |
+
return d
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
func _midpoint(verts: Array, cache: Dictionary, a: int, b: int) -> int:
|
| 222 |
+
var key := "%d_%d" % [mini(a, b), maxi(a, b)]
|
| 223 |
+
if cache.has(key):
|
| 224 |
+
return cache[key]
|
| 225 |
+
var mid: Vector3 = (verts[a] + verts[b]) * 0.5
|
| 226 |
+
verts.append(mid)
|
| 227 |
+
var idx := verts.size() - 1
|
| 228 |
+
cache[key] = idx
|
| 229 |
+
return idx
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
## A low-poly "Suzanne"-style monkey head silhouette (ears + cranium + snout).
|
| 233 |
+
## Not bit-identical to Blender's Suzanne, but generated purely from code.
|
| 234 |
+
func suzanne(scale: float) -> Dictionary:
|
| 235 |
+
var d := _make_mesh_data()
|
| 236 |
+
# Cranium: squashed sphere
|
| 237 |
+
var cranium := uv_sphere(10, 16, 0.6)
|
| 238 |
+
_append(d, cranium["verts"], cranium["indices"], cranium["uvs"], Vector3(0, 0.1, 0) * scale, scale)
|
| 239 |
+
# Snout: a cube-ish extension forward
|
| 240 |
+
var snout := cube(0.4)
|
| 241 |
+
_append(d, snout["verts"], snout["indices"], snout["uvs"], Vector3(0, -0.1, 0.5) * scale, scale)
|
| 242 |
+
# Ears: two flat spheres left/right
|
| 243 |
+
var ear_l := uv_sphere(6, 10, 0.25)
|
| 244 |
+
_append(d, ear_l["verts"], ear_l["indices"], ear_l["uvs"], Vector3(-0.6, 0.35, 0) * scale, scale)
|
| 245 |
+
var ear_r := uv_sphere(6, 10, 0.25)
|
| 246 |
+
_append(d, ear_r["verts"], ear_r["indices"], ear_r["uvs"], Vector3(0.6, 0.35, 0) * scale, scale)
|
| 247 |
+
return d
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
## Merge a sub-mesh into a target MeshData with an offset and uniform scale.
|
| 251 |
+
func _append(dst: Dictionary, verts: PackedVector3Array, indices: PackedInt32Array, uvs: PackedVector2Array, offset: Vector3, s: float) -> void:
|
| 252 |
+
var base: int = dst["verts"].size()
|
| 253 |
+
for v in verts:
|
| 254 |
+
dst["verts"].append(v * s + offset)
|
| 255 |
+
for u in uvs:
|
| 256 |
+
dst["uvs"].append(u)
|
| 257 |
+
for i in indices:
|
| 258 |
+
dst["indices"].append(base + i)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
## --- Instantiation helper ---------------------------------------------------
|
| 262 |
+
|
| 263 |
+
## Build a MeshInstance3D from a generated MeshData and attach under Geometry.
|
| 264 |
+
## Returns the instance (caller owns its lifetime; use MeshPool.release on free).
|
| 265 |
+
func spawn(kind: String, params: Dictionary = {}) -> MeshInstance3D:
|
| 266 |
+
var data := generate(kind, params)
|
| 267 |
+
return spawn_from_data(data, kind)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
func spawn_from_data(data: Dictionary, name_hint: String = "mesh") -> MeshInstance3D:
|
| 271 |
+
var mesh := MeshPool.acquire_mesh()
|
| 272 |
+
VertexBufferManager.commit(data["verts"], data["indices"], data["uvs"], mesh)
|
| 273 |
+
var mi := MeshInstance3D.new()
|
| 274 |
+
mi.name = name_hint.capitalize()
|
| 275 |
+
mi.mesh = mesh
|
| 276 |
+
var mat := StandardMaterial3D.new()
|
| 277 |
+
mat.albedo_color = Color(0.7, 0.72, 0.8)
|
| 278 |
+
mat.shading_mode = BaseMaterial3D.SHADING_MODE_PER_PIXEL
|
| 279 |
+
mi.material_override = mat
|
| 280 |
+
if _root != null:
|
| 281 |
+
_root.add_child(mi)
|
| 282 |
+
return mi
|
scripts/modules/mesh_gen.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://mh20ymrsudgf
|
scripts/modules/rigging.gd
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## RiggingModule - armature creation and automatic vertex weighting (Features 41-60).
|
| 4 |
+
##
|
| 5 |
+
## Creates a Skeleton3D, lets the user add bones as a transform hierarchy, and
|
| 6 |
+
## binds skinning data (ARRAY_BONES + ARRAY_WEIGHTS) to a MeshInstance3D using
|
| 7 |
+
## inverse-distance weighting against bone segments. The result is a normal,
|
| 8 |
+
## engine-native skinned mesh - no custom shader needed.
|
| 9 |
+
##
|
| 10 |
+
|
| 11 |
+
## Bone definitions the tool builds. Each entry: { name, parent, position, tail }
|
| 12 |
+
var _bones: Array[Dictionary] = []
|
| 13 |
+
var _skeleton: Skeleton3D = null
|
| 14 |
+
var _host: Node = null
|
| 15 |
+
var _bound_mesh: MeshInstance3D = null
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
func _activate(host: Node) -> void:
|
| 19 |
+
_host = host
|
| 20 |
+
StudioBus.object_selected.connect(_on_object_selected)
|
| 21 |
+
print("[Rigging] activated")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
func _deactivate() -> void:
|
| 25 |
+
if StudioBus.object_selected.is_connected(_on_object_selected):
|
| 26 |
+
StudioBus.object_selected.disconnect(_on_object_selected)
|
| 27 |
+
_host = null
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
func _on_object_selected(node: Node3D) -> void:
|
| 31 |
+
_bound_mesh = node as MeshInstance3D
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
## --- Armature creation ------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
## Add a bone. parent="" means root. Returns the new bone index.
|
| 37 |
+
func add_bone(name: String, parent: String, position: Vector3, tail: Vector3 = Vector3.ZERO) -> int:
|
| 38 |
+
_ensure_skeleton()
|
| 39 |
+
var parent_idx := -1
|
| 40 |
+
if parent != "":
|
| 41 |
+
parent_idx = _find_bone(parent)
|
| 42 |
+
var idx := _skeleton.get_bone_count()
|
| 43 |
+
_skeleton.add_bone(name)
|
| 44 |
+
if parent_idx >= 0:
|
| 45 |
+
_skeleton.set_bone_parent(idx, parent_idx)
|
| 46 |
+
var rest := Transform3D()
|
| 47 |
+
# Point the bone's -Z toward its tail so rotations look natural.
|
| 48 |
+
var dir := (tail - position)
|
| 49 |
+
if dir.length_squared() > 0.000001:
|
| 50 |
+
rest = Transform3D(_look_at_basis(dir.normalized()), position)
|
| 51 |
+
else:
|
| 52 |
+
rest = Transform3D(Basis.IDENTITY, position)
|
| 53 |
+
_skeleton.set_bone_rest(idx, rest)
|
| 54 |
+
_skeleton.set_bone_pose_position(idx, position)
|
| 55 |
+
_bones.append({"name": name, "parent": parent, "index": idx})
|
| 56 |
+
StudioBus.scene_modified.emit()
|
| 57 |
+
return idx
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
func _ensure_skeleton() -> void:
|
| 61 |
+
if _skeleton != null and is_instance_valid(_skeleton):
|
| 62 |
+
return
|
| 63 |
+
_skeleton = Skeleton3D.new()
|
| 64 |
+
_skeleton.name = "Armature"
|
| 65 |
+
if _host != null:
|
| 66 |
+
_host.get_node_or_null("Rig").add_child(_skeleton) if _host.has_node("Rig") else _host.add_child(_skeleton)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
func _find_bone(name: String) -> int:
|
| 70 |
+
if _skeleton == null:
|
| 71 |
+
return -1
|
| 72 |
+
for i in range(_skeleton.get_bone_count()):
|
| 73 |
+
if _skeleton.get_bone_name(i) == name:
|
| 74 |
+
return i
|
| 75 |
+
return -1
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
func _look_at_basis(dir: Vector3) -> Basis:
|
| 79 |
+
var up := Vector3.UP
|
| 80 |
+
if absf(dir.dot(up)) > 0.99:
|
| 81 |
+
up = Vector3.RIGHT
|
| 82 |
+
var right := dir.cross(up).normalized()
|
| 83 |
+
var real_up := right.cross(dir).normalized()
|
| 84 |
+
return Basis(right, real_up, -dir)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
## Build a simple starter humanoid-ish spine + limbs for a mesh's bounds.
|
| 88 |
+
func generate_default_armature(mesh: MeshInstance3D) -> void:
|
| 89 |
+
if mesh == null or mesh.mesh == null:
|
| 90 |
+
return
|
| 91 |
+
var size := mesh.mesh.get_aabb().size
|
| 92 |
+
var h := size.y
|
| 93 |
+
add_bone("root", "", Vector3.ZERO, Vector3(0, h * 0.1, 0))
|
| 94 |
+
add_bone("spine", "root", Vector3(0, h * 0.1, 0), Vector3(0, h * 0.35, 0))
|
| 95 |
+
add_bone("chest", "spine", Vector3(0, h * 0.35, 0), Vector3(0, h * 0.6, 0))
|
| 96 |
+
add_bone("neck", "chest", Vector3(0, h * 0.6, 0), Vector3(0, h * 0.72, 0))
|
| 97 |
+
add_bone("head", "neck", Vector3(0, h * 0.72, 0), Vector3(0, h * 0.95, 0))
|
| 98 |
+
add_bone("arm_l", "chest", Vector3(-size.x * 0.4, h * 0.6, 0), Vector3(-size.x * 0.9, h * 0.45, 0))
|
| 99 |
+
add_bone("arm_r", "chest", Vector3(size.x * 0.4, h * 0.6, 0), Vector3(size.x * 0.9, h * 0.45, 0))
|
| 100 |
+
add_bone("leg_l", "root", Vector3(-size.x * 0.2, 0, 0), Vector3(-size.x * 0.2, -h * 0.5, 0))
|
| 101 |
+
add_bone("leg_r", "root", Vector3(size.x * 0.2, 0, 0), Vector3(size.x * 0.2, -h * 0.5, 0))
|
| 102 |
+
bind_weights(mesh)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
## --- Automatic weight binding -----------------------------------------------
|
| 106 |
+
|
| 107 |
+
## Compute skinning arrays using inverse-distance to bone segments and apply.
|
| 108 |
+
## `max_influences` must be <= 4 for the standard mobile skinning path.
|
| 109 |
+
func bind_weights(mesh: MeshInstance3D, max_influences: int = 4) -> void:
|
| 110 |
+
if mesh == null or _skeleton == null or mesh.mesh == null or mesh.mesh.get_surface_count() == 0:
|
| 111 |
+
return
|
| 112 |
+
var arr := mesh.mesh.surface_get_arrays(0)
|
| 113 |
+
var verts: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
|
| 114 |
+
var n := verts.size()
|
| 115 |
+
var bone_count := _skeleton.get_bone_count()
|
| 116 |
+
var bone_segments: Array = []
|
| 117 |
+
for i in range(bone_count):
|
| 118 |
+
var pos := _skeleton.get_bone_global_rest(i).origin
|
| 119 |
+
bone_segments.append({"idx": i, "pos": pos})
|
| 120 |
+
|
| 121 |
+
var vbones := PackedInt32Array()
|
| 122 |
+
var vweights := PackedFloat32Array()
|
| 123 |
+
vbones.resize(n * 4)
|
| 124 |
+
vweights.resize(n * 4)
|
| 125 |
+
var to_mesh := mesh.global_transform.affine_inverse()
|
| 126 |
+
|
| 127 |
+
for vi in range(n):
|
| 128 |
+
var p := verts[vi]
|
| 129 |
+
# Rank bones by distance to their head position.
|
| 130 |
+
var dists: Array = []
|
| 131 |
+
for b in bone_segments:
|
| 132 |
+
var local_bone: Vector3 = to_mesh * (mesh.global_transform * b["pos"])
|
| 133 |
+
dists.append({"idx": b["idx"], "d": p.distance_to(local_bone)})
|
| 134 |
+
dists.sort_custom(func(a, b): return a["d"] < b["d"])
|
| 135 |
+
var influ: int = mini(max_influences, dists.size())
|
| 136 |
+
var wsum := 0.0
|
| 137 |
+
var ws: Array[float] = []
|
| 138 |
+
for k in range(influ):
|
| 139 |
+
var d: float = maxf(0.0001, dists[k]["d"])
|
| 140 |
+
var w := 1.0 / (d * d)
|
| 141 |
+
ws.append(w)
|
| 142 |
+
wsum += w
|
| 143 |
+
for slot in range(4):
|
| 144 |
+
var base := vi * 4 + slot
|
| 145 |
+
if slot < influ and wsum > 0.0:
|
| 146 |
+
vbones[base] = dists[slot]["idx"]
|
| 147 |
+
vweights[base] = ws[slot] / wsum
|
| 148 |
+
else:
|
| 149 |
+
vbones[base] = 0
|
| 150 |
+
vweights[base] = 0.0
|
| 151 |
+
|
| 152 |
+
# Rebuild the mesh surface with skinning channels added.
|
| 153 |
+
arr[Mesh.ARRAY_BONES] = vbones
|
| 154 |
+
arr[Mesh.ARRAY_WEIGHTS] = vweights
|
| 155 |
+
var new_mesh := MeshPool.acquire_mesh()
|
| 156 |
+
new_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arr)
|
| 157 |
+
mesh.mesh = new_mesh
|
| 158 |
+
mesh.skeleton = mesh.get_path_to(_skeleton)
|
| 159 |
+
# A Skin must be bound so the renderer knows which skeleton supplies the
|
| 160 |
+
# bone transforms for these indices. add_bind(bone_index, rest_pose).
|
| 161 |
+
var skin := Skin.new()
|
| 162 |
+
for i in range(bone_count):
|
| 163 |
+
skin.add_bind(i, _skeleton.get_bone_global_rest(i))
|
| 164 |
+
mesh.skin = skin
|
| 165 |
+
StudioBus.scene_modified.emit()
|
| 166 |
+
print("[Rigging] bound %d verts to %d bones" % [n, bone_count])
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
func bone_count() -> int:
|
| 170 |
+
return _skeleton.get_bone_count() if _skeleton != null else 0
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
func get_skeleton() -> Skeleton3D:
|
| 174 |
+
return _skeleton
|
scripts/modules/rigging.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://dedkqt37ilf6u
|
scripts/modules/sculpt.gd
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## SculptModule - vertex-based sculpting brushes (Features 21-40).
|
| 4 |
+
##
|
| 5 |
+
## Works directly on a MeshInstance3D's surface arrays. A stroke is:
|
| 6 |
+
## begin(pos) -> apply(pos, delta) x N -> end()
|
| 7 |
+
## All applies accumulate into CPU PackedArrays; a single GPU upload happens in
|
| 8 |
+
## end() (and optionally on a timer) so we do NOT upload per touch-move event.
|
| 9 |
+
##
|
| 10 |
+
## Brushes:
|
| 11 |
+
## draw - push vertices along the surface normal / view dir
|
| 12 |
+
## grab - drag vertices inside the radius with the pointer
|
| 13 |
+
## pinch - pull vertices toward the brush center
|
| 14 |
+
## smooth - laplacian relax toward neighbour average
|
| 15 |
+
## flatten - move vertices toward the local average plane
|
| 16 |
+
##
|
| 17 |
+
|
| 18 |
+
@export var radius: float = 0.5
|
| 19 |
+
@export var strength: float = 0.5
|
| 20 |
+
@export var invert: bool = false # hold shift / tap toggle to reverse
|
| 21 |
+
|
| 22 |
+
var _host: Node = null
|
| 23 |
+
var _target: MeshInstance3D = null
|
| 24 |
+
var _verts: PackedVector3Array = PackedVector3Array()
|
| 25 |
+
var _indices: PackedInt32Array = PackedInt32Array()
|
| 26 |
+
var _uvs: PackedVector2Array = PackedVector2Array()
|
| 27 |
+
var _base_verts: PackedVector3Array = PackedVector3Array()
|
| 28 |
+
var _dirty := false
|
| 29 |
+
var _stroke_active := false
|
| 30 |
+
var _brush_id := "draw"
|
| 31 |
+
var _grab_origin := Vector3.ZERO
|
| 32 |
+
var _grab_delta := Vector3.ZERO
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
func _activate(host: Node) -> void:
|
| 36 |
+
_host = host
|
| 37 |
+
StudioBus.brush_changed.connect(_on_brush_changed)
|
| 38 |
+
StudioBus.brush_radius_changed.connect(func(r: float): radius = r)
|
| 39 |
+
StudioBus.brush_strength_changed.connect(func(s: float): strength = s)
|
| 40 |
+
StudioBus.object_selected.connect(_on_object_selected)
|
| 41 |
+
print("[Sculpt] activated")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
func _deactivate() -> void:
|
| 45 |
+
flush()
|
| 46 |
+
if StudioBus.brush_changed.is_connected(_on_brush_changed):
|
| 47 |
+
StudioBus.brush_changed.disconnect(_on_brush_changed)
|
| 48 |
+
if StudioBus.object_selected.is_connected(_on_object_selected):
|
| 49 |
+
StudioBus.object_selected.disconnect(_on_object_selected)
|
| 50 |
+
_host = null
|
| 51 |
+
_target = null
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
func _on_brush_changed(id: String) -> void:
|
| 55 |
+
_brush_id = id
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
func _on_object_selected(node: Node3D) -> void:
|
| 59 |
+
set_target(node as MeshInstance3D)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
## Bind a MeshInstance3D and cache its arrays.
|
| 63 |
+
func set_target(mi: MeshInstance3D) -> void:
|
| 64 |
+
_target = mi
|
| 65 |
+
if mi == null or mi.mesh == null or mi.mesh.get_surface_count() == 0:
|
| 66 |
+
return
|
| 67 |
+
var arr := mi.mesh.surface_get_arrays(0)
|
| 68 |
+
_verts = arr[Mesh.ARRAY_VERTEX]
|
| 69 |
+
_uvs = arr[Mesh.ARRAY_TEX_UV] if arr.size() > Mesh.ARRAY_TEX_UV and arr[Mesh.ARRAY_TEX_UV] != null else PackedVector2Array()
|
| 70 |
+
_indices = arr[Mesh.ARRAY_INDEX] if arr.size() > Mesh.ARRAY_INDEX and arr[Mesh.ARRAY_INDEX] != null else PackedInt32Array()
|
| 71 |
+
_base_verts = _verts.duplicate()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
func begin(world_pos: Vector3) -> void:
|
| 75 |
+
if _verts.is_empty():
|
| 76 |
+
return
|
| 77 |
+
_stroke_active = true
|
| 78 |
+
_dirty = false
|
| 79 |
+
_grab_origin = world_pos
|
| 80 |
+
_grab_delta = Vector3.ZERO
|
| 81 |
+
StudioBus.stroke_begin.emit(world_pos)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
func apply(world_pos: Vector3, move_delta: Vector3 = Vector3.ZERO) -> void:
|
| 85 |
+
if not _stroke_active or _verts.is_empty():
|
| 86 |
+
return
|
| 87 |
+
match _brush_id:
|
| 88 |
+
"draw": _brush_draw(world_pos, move_delta)
|
| 89 |
+
"grab": _brush_grab(world_pos, move_delta)
|
| 90 |
+
"pinch": _brush_pinch(world_pos)
|
| 91 |
+
"smooth": _brush_smooth(world_pos)
|
| 92 |
+
"flatten": _brush_flatten(world_pos)
|
| 93 |
+
_dirty = true
|
| 94 |
+
StudioBus.stroke_apply.emit(world_pos, move_delta)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
func end() -> void:
|
| 98 |
+
if not _stroke_active:
|
| 99 |
+
return
|
| 100 |
+
_stroke_active = false
|
| 101 |
+
flush()
|
| 102 |
+
StudioBus.stroke_end.emit()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
## Upload the accumulated CPU edits to the GPU in one shot.
|
| 106 |
+
func flush() -> void:
|
| 107 |
+
if not _dirty or _target == null or _target.mesh == null:
|
| 108 |
+
return
|
| 109 |
+
VertexBufferManager.commit(_verts, _indices, _uvs, _target.mesh)
|
| 110 |
+
_base_verts = _verts.duplicate()
|
| 111 |
+
_dirty = false
|
| 112 |
+
StudioBus.scene_modified.emit()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
## --- Brush implementations --------------------------------------------------
|
| 116 |
+
|
| 117 |
+
func _falloff(dist: float) -> float:
|
| 118 |
+
if dist >= radius:
|
| 119 |
+
return 0.0
|
| 120 |
+
var t := 1.0 - (dist / radius)
|
| 121 |
+
return t * t * (3.0 - 2.0 * t) # smoothstep
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
func _brush_draw(center: Vector3, move_delta: Vector3) -> void:
|
| 125 |
+
var dir := move_delta.normalized() if move_delta.length_squared() > 0.000001 else Vector3.UP
|
| 126 |
+
var sign := -1.0 if invert else 1.0
|
| 127 |
+
var amp := strength * radius * 0.25 * sign
|
| 128 |
+
var r2 := radius * radius
|
| 129 |
+
for i in range(_verts.size()):
|
| 130 |
+
var d2 := _verts[i].distance_squared_to(center)
|
| 131 |
+
if d2 < r2:
|
| 132 |
+
var w := _falloff(sqrt(d2))
|
| 133 |
+
_verts[i] = _verts[i] + dir * amp * w
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
func _brush_grab(center: Vector3, move_delta: Vector3) -> void:
|
| 137 |
+
_grab_delta += move_delta
|
| 138 |
+
var r2 := radius * radius
|
| 139 |
+
# Move relative to where the stroke started for a stable, non-drifting feel.
|
| 140 |
+
for i in range(_verts.size()):
|
| 141 |
+
var src := _base_verts[i]
|
| 142 |
+
var d2 := src.distance_squared_to(_grab_origin)
|
| 143 |
+
if d2 < r2:
|
| 144 |
+
var w := _falloff(sqrt(d2))
|
| 145 |
+
_verts[i] = src + _grab_delta * w
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
func _brush_pinch(center: Vector3) -> void:
|
| 149 |
+
var sign := -1.0 if invert else 1.0
|
| 150 |
+
var r2 := radius * radius
|
| 151 |
+
for i in range(_verts.size()):
|
| 152 |
+
var v := _verts[i]
|
| 153 |
+
var d2 := v.distance_squared_to(center)
|
| 154 |
+
if d2 < r2:
|
| 155 |
+
var w := _falloff(sqrt(d2))
|
| 156 |
+
var to_center := (center - v)
|
| 157 |
+
_verts[i] = v + to_center * strength * 0.15 * w * sign
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
func _brush_smooth(center: Vector3) -> void:
|
| 161 |
+
# Approximate neighbour average using the vertex's triangle neighbours.
|
| 162 |
+
var sums := PackedVector3Array()
|
| 163 |
+
var counts := PackedInt32Array()
|
| 164 |
+
sums.resize(_verts.size())
|
| 165 |
+
counts.resize(_verts.size())
|
| 166 |
+
var tri := _indices.size() / 3
|
| 167 |
+
for t in range(tri):
|
| 168 |
+
var a := _indices[t * 3]
|
| 169 |
+
var b := _indices[t * 3 + 1]
|
| 170 |
+
var c := _indices[t * 3 + 2]
|
| 171 |
+
for pair in [[a, b], [b, c], [c, a]]:
|
| 172 |
+
var k: int = pair[1]
|
| 173 |
+
sums[k] = sums[k] + _verts[pair[0]]
|
| 174 |
+
counts[k] = counts[k] + 1
|
| 175 |
+
var k2: int = pair[0]
|
| 176 |
+
sums[k2] = sums[k2] + _verts[k]
|
| 177 |
+
counts[k2] = counts[k2] + 1
|
| 178 |
+
var r2 := radius * radius
|
| 179 |
+
for i in range(_verts.size()):
|
| 180 |
+
if _verts[i].distance_squared_to(center) < r2 and counts[i] > 0:
|
| 181 |
+
var avg := sums[i] / float(counts[i])
|
| 182 |
+
var w := _falloff(_verts[i].distance_to(center))
|
| 183 |
+
_verts[i] = _verts[i].lerp(avg, clampf(strength * w, 0.0, 1.0))
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
func _brush_flatten(center: Vector3) -> void:
|
| 187 |
+
var r2 := radius * radius
|
| 188 |
+
# Find the average normal of affected verts -> local plane.
|
| 189 |
+
var avg_normal := Vector3.ZERO
|
| 190 |
+
var affected: Array[int] = []
|
| 191 |
+
for i in range(_verts.size()):
|
| 192 |
+
if _verts[i].distance_squared_to(center) < r2:
|
| 193 |
+
affected.append(i)
|
| 194 |
+
if affected.is_empty():
|
| 195 |
+
return
|
| 196 |
+
for i in affected:
|
| 197 |
+
avg_normal = avg_normal + (_base_verts[i] - center)
|
| 198 |
+
avg_normal = avg_normal.normalized() if avg_normal.length_squared() > 0.000001 else Vector3.UP
|
| 199 |
+
for i in affected:
|
| 200 |
+
var v := _verts[i]
|
| 201 |
+
var dist_to_plane := v.dot(avg_normal) - center.dot(avg_normal)
|
| 202 |
+
var w := _falloff(v.distance_to(center))
|
| 203 |
+
_verts[i] = v - avg_normal * dist_to_plane * clampf(strength * w, 0.0, 1.0)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
## Ray from camera through screen point; returns closest approach to mesh AABB.
|
| 207 |
+
## A physics collider is preferred; this fallback keeps sculpting usable even
|
| 208 |
+
## when the mesh has no collision shape (common right after generation).
|
| 209 |
+
func raycast_screen(camera: Camera3D, screen_pos: Vector2) -> Dictionary:
|
| 210 |
+
if camera == null or _target == null:
|
| 211 |
+
return {"hit": false}
|
| 212 |
+
var origin := camera.project_ray_origin(screen_pos)
|
| 213 |
+
var dir := camera.project_ray_normal(screen_pos)
|
| 214 |
+
var space := _host.get_viewport().world_3d.direct_space_state
|
| 215 |
+
var q := PhysicsRayQueryParameters3D.create(origin, origin + dir * 1000.0)
|
| 216 |
+
q.collide_with_areas = true
|
| 217 |
+
var res := space.intersect_ray(q)
|
| 218 |
+
if res.is_empty():
|
| 219 |
+
# Fallback: intersect ray with the mesh AABB as an approximation.
|
| 220 |
+
var aabb := _target.mesh.get_aabb()
|
| 221 |
+
var box_pos := _target.global_transform * aabb.position
|
| 222 |
+
var t := _ray_box(origin, dir, _target.global_transform * (aabb.position + aabb.size * 0.5), aabb.size.length() * 0.5)
|
| 223 |
+
if t > 0.0:
|
| 224 |
+
return {"hit": true, "position": origin + dir * t, "normal": Vector3.UP}
|
| 225 |
+
return {"hit": false}
|
| 226 |
+
return {"hit": true, "position": res["position"], "normal": res["normal"]}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
func _ray_box(origin: Vector3, dir: Vector3, center: Vector3, r: float) -> float:
|
| 230 |
+
# Sphere approximation of the mesh bounds for the fallback ray test.
|
| 231 |
+
var oc := origin - center
|
| 232 |
+
var b := oc.dot(dir)
|
| 233 |
+
var c := oc.dot(oc) - r * r
|
| 234 |
+
var disc := b * b - c
|
| 235 |
+
if disc < 0.0:
|
| 236 |
+
return -1.0
|
| 237 |
+
var t := -b - sqrt(disc)
|
| 238 |
+
return t if t > 0.0 else (-b + sqrt(disc))
|
scripts/modules/sculpt.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://e464m7f2eegd
|
scripts/modules/simulation.gd
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## SimulationModule - lightweight mass-spring solver for cloth & hair
|
| 4 |
+
## (Features 61-80), tuned for mobile CPUs.
|
| 5 |
+
##
|
| 6 |
+
## Approach: a fixed grid of particles connected by springs (structural,
|
| 7 |
+
## shear, bend). Integration uses fixed-dt sub-stepping with Verlet and
|
| 8 |
+
## constraint relaxation (Jakobsen) which is stable at low iteration counts -
|
| 9 |
+
## far cheaper than a full PBD/XPBD solver while looking convincing.
|
| 10 |
+
##
|
| 11 |
+
## Memory: particle + spring arrays are PackedArrays; nothing is allocated per
|
| 12 |
+
## frame. The whole sim is torn down by FeatureRegistry.deactivate() when the
|
| 13 |
+
## user leaves the module, so its RAM returns to the pool.
|
| 14 |
+
##
|
| 15 |
+
|
| 16 |
+
## sim_id -> { settings, particles, springs, mesh, instance }
|
| 17 |
+
var _sims: Dictionary = {}
|
| 18 |
+
var _host: Node = null
|
| 19 |
+
var _accumulator: float = 0.0
|
| 20 |
+
|
| 21 |
+
const FIXED_DT := 1.0 / 30.0
|
| 22 |
+
const MAX_SUBSTEPS := 4
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
func _activate(host: Node) -> void:
|
| 26 |
+
_host = host
|
| 27 |
+
set_process(true)
|
| 28 |
+
StudioBus.sim_toggled.connect(_on_sim_toggled)
|
| 29 |
+
StudioBus.sim_settings_changed.connect(_on_settings_changed)
|
| 30 |
+
print("[Simulation] activated")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
func _deactivate() -> void:
|
| 34 |
+
if StudioBus.sim_toggled.is_connected(_on_sim_toggled):
|
| 35 |
+
StudioBus.sim_toggled.disconnect(_on_sim_toggled)
|
| 36 |
+
if StudioBus.sim_settings_changed.is_connected(_on_settings_changed):
|
| 37 |
+
StudioBus.sim_settings_changed.disconnect(_on_settings_changed)
|
| 38 |
+
_sims.clear()
|
| 39 |
+
_host = null
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
func _on_sim_toggled(sim_id: String, enabled: bool) -> void:
|
| 43 |
+
if enabled and not _sims.has(sim_id):
|
| 44 |
+
_build_default(sim_id)
|
| 45 |
+
if _sims.has(sim_id):
|
| 46 |
+
_sims[sim_id]["settings"]["enabled"] = enabled
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
func _on_settings_changed(sim_id: String, settings: Dictionary) -> void:
|
| 50 |
+
if not _sims.has(sim_id):
|
| 51 |
+
return
|
| 52 |
+
for k in settings:
|
| 53 |
+
_sims[sim_id]["settings"][k] = settings[k]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
func _build_default(sim_id: String) -> void:
|
| 57 |
+
if sim_id == "sim.cloth":
|
| 58 |
+
create_cloth("sim.cloth", 12, 12, 1.0)
|
| 59 |
+
elif sim_id == "sim.hair":
|
| 60 |
+
create_hair("sim.hair", 10, 10)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
## --- Cloth ------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
## Create a cloth grid of cols x rows, size meters wide (square), hung from the
|
| 66 |
+
## top edge and simulated against gravity + wind.
|
| 67 |
+
func create_cloth(sim_id: String, cols: int, rows: int, size: float) -> void:
|
| 68 |
+
cols = maxi(2, cols)
|
| 69 |
+
rows = maxi(2, rows)
|
| 70 |
+
var particles := PackedVector3Array()
|
| 71 |
+
var pinned := PackedByteArray()
|
| 72 |
+
var step := size / float(cols - 1)
|
| 73 |
+
for r in range(rows):
|
| 74 |
+
for c in range(cols):
|
| 75 |
+
particles.append(Vector3((c - (cols - 1) * 0.5) * step, -r * step, 0.0))
|
| 76 |
+
# Pin the top row so the cloth hangs.
|
| 77 |
+
pinned.append(1 if r == 0 else 0)
|
| 78 |
+
var springs := PackedInt32Array()
|
| 79 |
+
var rest := PackedFloat32Array()
|
| 80 |
+
_add_structural(cols, rows, particles, springs, rest)
|
| 81 |
+
_add_shear(cols, rows, particles, springs, rest)
|
| 82 |
+
var mesh := MeshPool.acquire_mesh()
|
| 83 |
+
var mi := _make_instance("Cloth")
|
| 84 |
+
_sims[sim_id] = {
|
| 85 |
+
"settings": {
|
| 86 |
+
"enabled": true, "gravity": -9.8, "wind": Vector3(0.6, 0.0, 0.2),
|
| 87 |
+
"damping": 0.02, "iterations": 6, "stiffness": 1.0, "cols": cols, "rows": rows,
|
| 88 |
+
},
|
| 89 |
+
"particles": particles, "prev": particles.duplicate(), "pinned": pinned,
|
| 90 |
+
"springs": springs, "rest": rest, "mesh": mesh, "instance": mi,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
func _add_structural(cols: int, rows: int, pos: PackedVector3Array,
|
| 95 |
+
springs: PackedInt32Array, rest: PackedFloat32Array) -> void:
|
| 96 |
+
for r in range(rows):
|
| 97 |
+
for c in range(cols):
|
| 98 |
+
var i := r * cols + c
|
| 99 |
+
if c + 1 < cols:
|
| 100 |
+
_push_spring(i, i + 1, pos, springs, rest)
|
| 101 |
+
if r + 1 < rows:
|
| 102 |
+
_push_spring(i, i + cols, pos, springs, rest)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
func _add_shear(cols: int, rows: int, pos: PackedVector3Array,
|
| 106 |
+
springs: PackedInt32Array, rest: PackedFloat32Array) -> void:
|
| 107 |
+
for r in range(rows - 1):
|
| 108 |
+
for c in range(cols - 1):
|
| 109 |
+
var i := r * cols + c
|
| 110 |
+
_push_spring(i, i + cols + 1, pos, springs, rest)
|
| 111 |
+
_push_spring(i + 1, i + cols, pos, springs, rest)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
func _push_spring(a: int, b: int, pos: PackedVector3Array,
|
| 115 |
+
springs: PackedInt32Array, rest: PackedFloat32Array) -> void:
|
| 116 |
+
springs.append(a)
|
| 117 |
+
springs.append(b)
|
| 118 |
+
rest.append(pos[a].distance_to(pos[b]))
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
## --- Hair -------------------------------------------------------------------
|
| 122 |
+
|
| 123 |
+
## Create `strands` strands, each `segments` long, rooted along a horizontal line.
|
| 124 |
+
func create_hair(sim_id: String, strands: int, segments: int) -> void:
|
| 125 |
+
var particles := PackedVector3Array()
|
| 126 |
+
var pinned := PackedByteArray()
|
| 127 |
+
var springs := PackedInt32Array()
|
| 128 |
+
var rest := PackedFloat32Array()
|
| 129 |
+
var spread := 1.0
|
| 130 |
+
for s in range(strands):
|
| 131 |
+
var x := (float(s) / float(maxi(1, strands - 1)) - 0.5) * spread
|
| 132 |
+
var base := particles.size()
|
| 133 |
+
for seg in range(segments):
|
| 134 |
+
particles.append(Vector3(x, 0.5 - seg * 0.08, 0.0))
|
| 135 |
+
pinned.append(1 if seg == 0 else 0)
|
| 136 |
+
for seg in range(segments - 1):
|
| 137 |
+
_push_spring(base + seg, base + seg + 1, particles, springs, rest)
|
| 138 |
+
var mesh := MeshPool.acquire_mesh()
|
| 139 |
+
var mi := _make_instance("Hair")
|
| 140 |
+
_sims[sim_id] = {
|
| 141 |
+
"settings": {
|
| 142 |
+
"enabled": true, "gravity": -9.8, "wind": Vector3(0.2, 0.0, 0.4),
|
| 143 |
+
"damping": 0.05, "iterations": 4, "stiffness": 0.8,
|
| 144 |
+
"strands": strands, "segments": segments,
|
| 145 |
+
},
|
| 146 |
+
"particles": particles, "prev": particles.duplicate(), "pinned": pinned,
|
| 147 |
+
"springs": springs, "rest": rest, "mesh": mesh, "instance": mi,
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
func _make_instance(name: String) -> MeshInstance3D:
|
| 152 |
+
var mi := MeshInstance3D.new()
|
| 153 |
+
mi.name = name
|
| 154 |
+
var mat := StandardMaterial3D.new()
|
| 155 |
+
mat.albedo_color = Color(0.55, 0.6, 0.75)
|
| 156 |
+
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
| 157 |
+
mi.material_override = mat
|
| 158 |
+
if _host != null:
|
| 159 |
+
var geom := _host.get_node_or_null("Geometry")
|
| 160 |
+
(geom if geom else _host).add_child(mi)
|
| 161 |
+
# Collision is only needed for the rays; nothing to add here for now.
|
| 162 |
+
return mi
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
## --- Solver ----------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
func _process(delta: float) -> void:
|
| 168 |
+
var any := false
|
| 169 |
+
for id in _sims:
|
| 170 |
+
var sim: Dictionary = _sims[id]
|
| 171 |
+
if sim["settings"]["enabled"]:
|
| 172 |
+
any = true
|
| 173 |
+
break
|
| 174 |
+
if not any:
|
| 175 |
+
return
|
| 176 |
+
_accumulator += delta
|
| 177 |
+
var steps := 0
|
| 178 |
+
while _accumulator >= FIXED_DT and steps < MAX_SUBSTEPS:
|
| 179 |
+
for id in _sims:
|
| 180 |
+
var sim: Dictionary = _sims[id]
|
| 181 |
+
if sim["settings"]["enabled"]:
|
| 182 |
+
_step(sim)
|
| 183 |
+
_accumulator -= FIXED_DT
|
| 184 |
+
steps += 1
|
| 185 |
+
if steps >= MAX_SUBSTEPS:
|
| 186 |
+
_accumulator = 0.0 # drop backlog instead of spiralling
|
| 187 |
+
for id in _sims:
|
| 188 |
+
_upload(_sims[id])
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
## Verlet integration + Jacobi-style constraint relaxation.
|
| 192 |
+
func _step(sim: Dictionary) -> void:
|
| 193 |
+
var s: Dictionary = sim["settings"]
|
| 194 |
+
var pos: PackedVector3Array = sim["particles"]
|
| 195 |
+
var prev: PackedVector3Array = sim["prev"]
|
| 196 |
+
var pinned: PackedByteArray = sim["pinned"]
|
| 197 |
+
var gravity: float = s["gravity"]
|
| 198 |
+
var wind: Vector3 = s["wind"]
|
| 199 |
+
var damping: float = s["damping"]
|
| 200 |
+
var dt := FIXED_DT
|
| 201 |
+
|
| 202 |
+
for i in range(pos.size()):
|
| 203 |
+
if pinned[i] == 1:
|
| 204 |
+
continue
|
| 205 |
+
var velocity := (pos[i] - prev[i]) * (1.0 - damping)
|
| 206 |
+
prev[i] = pos[i]
|
| 207 |
+
var accel := Vector3(0, gravity, 0) + wind
|
| 208 |
+
pos[i] = pos[i] + velocity + accel * dt * dt
|
| 209 |
+
|
| 210 |
+
var springs: PackedInt32Array = sim["springs"]
|
| 211 |
+
var rest: PackedFloat32Array = sim["rest"]
|
| 212 |
+
var stiffness: float = s["stiffness"]
|
| 213 |
+
for _it in range(int(s["iterations"])):
|
| 214 |
+
for k in range(rest.size()):
|
| 215 |
+
var a := springs[k * 2]
|
| 216 |
+
var b := springs[k * 2 + 1]
|
| 217 |
+
var delta_v := pos[b] - pos[a]
|
| 218 |
+
var d := delta_v.length()
|
| 219 |
+
if d < 0.00001:
|
| 220 |
+
continue
|
| 221 |
+
var diff := (d - rest[k]) / d
|
| 222 |
+
var correction := delta_v * 0.5 * diff * stiffness
|
| 223 |
+
if pinned[a] == 0:
|
| 224 |
+
pos[a] = pos[a] + correction
|
| 225 |
+
if pinned[b] == 0:
|
| 226 |
+
pos[b] = pos[b] - correction
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
## Upload the current particle positions as a triangle strip / line mesh.
|
| 230 |
+
func _upload(sim: Dictionary) -> void:
|
| 231 |
+
var pos: PackedVector3Array = sim["particles"]
|
| 232 |
+
var mesh: ArrayMesh = sim["mesh"]
|
| 233 |
+
var cols: int = sim["settings"].get("cols", 0)
|
| 234 |
+
var rows: int = sim["settings"].get("rows", 0)
|
| 235 |
+
if cols > 1 and rows > 1:
|
| 236 |
+
_upload_cloth(pos, cols, rows, mesh)
|
| 237 |
+
else:
|
| 238 |
+
_upload_hair(pos, sim["settings"].get("strands", 0), sim["settings"].get("segments", 0), mesh)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
func _upload_cloth(pos: PackedVector3Array, cols: int, rows: int, mesh: ArrayMesh) -> void:
|
| 242 |
+
var indices := PackedInt32Array()
|
| 243 |
+
for r in range(rows - 1):
|
| 244 |
+
for c in range(cols - 1):
|
| 245 |
+
var i := r * cols + c
|
| 246 |
+
indices.append(i); indices.append(i + 1); indices.append(i + cols)
|
| 247 |
+
indices.append(i + 1); indices.append(i + cols + 1); indices.append(i + cols)
|
| 248 |
+
var uvs := PackedVector2Array()
|
| 249 |
+
uvs.resize(pos.size())
|
| 250 |
+
for i in range(pos.size()):
|
| 251 |
+
uvs[i] = Vector2(float(i % cols) / float(cols), float(i / cols) / float(rows))
|
| 252 |
+
var normals := VertexBufferManager.recompute_normals(pos, indices)
|
| 253 |
+
mesh.clear_surfaces()
|
| 254 |
+
var arrays: Array = []
|
| 255 |
+
arrays.resize(Mesh.ARRAY_MAX)
|
| 256 |
+
arrays[Mesh.ARRAY_VERTEX] = pos
|
| 257 |
+
arrays[Mesh.ARRAY_NORMAL] = normals
|
| 258 |
+
arrays[Mesh.ARRAY_TEX_UV] = uvs
|
| 259 |
+
arrays[Mesh.ARRAY_INDEX] = indices
|
| 260 |
+
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
func _upload_hair(pos: PackedVector3Array, strands: int, segments: int, mesh: ArrayMesh) -> void:
|
| 264 |
+
if strands <= 1 or segments <= 1:
|
| 265 |
+
return
|
| 266 |
+
var ribbons := PackedVector3Array()
|
| 267 |
+
var indices := PackedInt32Array()
|
| 268 |
+
var uvs := PackedVector2Array()
|
| 269 |
+
var width := 0.02
|
| 270 |
+
for s in range(strands):
|
| 271 |
+
var base := s * segments
|
| 272 |
+
var line_base := ribbons.size()
|
| 273 |
+
for seg in range(segments):
|
| 274 |
+
var p := pos[base + seg]
|
| 275 |
+
var tangent := Vector3.RIGHT
|
| 276 |
+
if seg + 1 < segments:
|
| 277 |
+
tangent = (pos[base + seg + 1] - p).normalized()
|
| 278 |
+
var side := tangent.cross(Vector3.FORWARD).normalized() * width
|
| 279 |
+
ribbons.append(p - side)
|
| 280 |
+
ribbons.append(p + side)
|
| 281 |
+
uvs.append(Vector2(0, float(seg) / float(segments)))
|
| 282 |
+
uvs.append(Vector2(1, float(seg) / float(segments)))
|
| 283 |
+
for seg in range(segments - 1):
|
| 284 |
+
var a := line_base + seg * 2
|
| 285 |
+
indices.append(a); indices.append(a + 1); indices.append(a + 2)
|
| 286 |
+
indices.append(a + 1); indices.append(a + 3); indices.append(a + 2)
|
| 287 |
+
var normals := VertexBufferManager.recompute_normals(ribbons, indices)
|
| 288 |
+
mesh.clear_surfaces()
|
| 289 |
+
var arrays: Array = []
|
| 290 |
+
arrays.resize(Mesh.ARRAY_MAX)
|
| 291 |
+
arrays[Mesh.ARRAY_VERTEX] = ribbons
|
| 292 |
+
arrays[Mesh.ARRAY_NORMAL] = normals
|
| 293 |
+
arrays[Mesh.ARRAY_TEX_UV] = uvs
|
| 294 |
+
arrays[Mesh.ARRAY_INDEX] = indices
|
| 295 |
+
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
func set_wind(sim_id: String, wind: Vector3) -> void:
|
| 299 |
+
if _sims.has(sim_id):
|
| 300 |
+
_sims[sim_id]["settings"]["wind"] = wind
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
func active_sim_count() -> int:
|
| 304 |
+
return _sims.size()
|
scripts/modules/simulation.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://by4bu3p6btqr8
|
scripts/modules/timeline.gd
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## TimelineModule - keyframe timeline for objects and bones (Features 81-100).
|
| 4 |
+
##
|
| 5 |
+
## Backed by a real Godot Animation resource + AnimationPlayer, so playback,
|
| 6 |
+
## interpolation and looping use engine-native code (fast, cached). The module
|
| 7 |
+
## exposes a touch-friendly API: scrub(), add_keyframe(), play(), pause().
|
| 8 |
+
##
|
| 9 |
+
|
| 10 |
+
@export var duration: float = 5.0
|
| 11 |
+
@export var fps: int = 30
|
| 12 |
+
|
| 13 |
+
var _host: Node = null
|
| 14 |
+
var _player: AnimationPlayer = null
|
| 15 |
+
var _anim: Animation = null
|
| 16 |
+
var _playhead: float = 0.0
|
| 17 |
+
var _playing := false
|
| 18 |
+
var _key_count := 0
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
func _activate(host: Node) -> void:
|
| 22 |
+
_host = host
|
| 23 |
+
_ensure_player()
|
| 24 |
+
print("[Timeline] activated (%.1fs @ %d fps)" % [duration, fps])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
func _deactivate() -> void:
|
| 28 |
+
_playing = false
|
| 29 |
+
_player = null
|
| 30 |
+
_anim = null
|
| 31 |
+
_host = null
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
func _ensure_player() -> void:
|
| 35 |
+
if _player != null and is_instance_valid(_player):
|
| 36 |
+
return
|
| 37 |
+
_anim = Animation.new()
|
| 38 |
+
_anim.length = duration
|
| 39 |
+
_anim.step = 1.0 / float(fps)
|
| 40 |
+
_player = AnimationPlayer.new()
|
| 41 |
+
_player.name = "TimelinePlayer"
|
| 42 |
+
if _host != null:
|
| 43 |
+
_host.add_child(_player)
|
| 44 |
+
var lib := AnimationLibrary.new()
|
| 45 |
+
lib.add_animation("main", _anim)
|
| 46 |
+
_player.add_animation_library("", lib)
|
| 47 |
+
_player.animation_finished.connect(_on_finished)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
func _process(delta: float) -> void:
|
| 51 |
+
if not _playing or _player == null:
|
| 52 |
+
return
|
| 53 |
+
_playhead = _player.current_animation_position
|
| 54 |
+
StudioBus.timeline_playhead.emit(_playhead)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
## --- Playback ---------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
func play(from: float = -1.0) -> void:
|
| 60 |
+
_ensure_player()
|
| 61 |
+
if from >= 0.0:
|
| 62 |
+
_player.seek(from, true)
|
| 63 |
+
_player.play("main")
|
| 64 |
+
_playing = true
|
| 65 |
+
StudioBus.playback_state_changed.emit(true)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
func pause() -> void:
|
| 69 |
+
if _player != null:
|
| 70 |
+
_player.pause()
|
| 71 |
+
_playing = false
|
| 72 |
+
StudioBus.playback_state_changed.emit(false)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
func stop() -> void:
|
| 76 |
+
if _player != null:
|
| 77 |
+
_player.stop()
|
| 78 |
+
_playhead = 0.0
|
| 79 |
+
_playing = false
|
| 80 |
+
StudioBus.playback_state_changed.emit(false)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
## Jump the playhead to `time` seconds (scrubbing).
|
| 84 |
+
func scrub(time: float) -> void:
|
| 85 |
+
_ensure_player()
|
| 86 |
+
time = clampf(time, 0.0, duration)
|
| 87 |
+
_playhead = time
|
| 88 |
+
_player.seek(time, true)
|
| 89 |
+
StudioBus.timeline_playhead.emit(time)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
func is_playing() -> bool:
|
| 93 |
+
return _playing
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
func playhead() -> float:
|
| 97 |
+
return _playhead
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
## --- Keyframes --------------------------------------------------------------
|
| 101 |
+
|
| 102 |
+
## Add a keyframe for a `property` (NodePath-style) on an object at `time`.
|
| 103 |
+
## For bones use property like "bones/Head:position" (Skeleton3D pose tracks).
|
| 104 |
+
func add_keyframe(target_node: Node3D, property: String, time: float, value: Variant) -> void:
|
| 105 |
+
_ensure_player()
|
| 106 |
+
time = clampf(time, 0.0, duration)
|
| 107 |
+
var path := _player.get_path_to(target_node)
|
| 108 |
+
if path == NodePath():
|
| 109 |
+
push_warning("Timeline: target is not under the player root")
|
| 110 |
+
return
|
| 111 |
+
var track := _find_or_create_track(String(path), property, value)
|
| 112 |
+
var key_idx := _anim.track_insert_key(track, time, value, 0.0)
|
| 113 |
+
if key_idx >= 0:
|
| 114 |
+
_key_count += 1
|
| 115 |
+
StudioBus.keyframe_added.emit(String(path), property, time)
|
| 116 |
+
StudioBus.scene_modified.emit()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
func _find_or_create_track(node_path: String, property: String, value: Variant) -> int:
|
| 120 |
+
var full := NodePath(node_path + ":" + property)
|
| 121 |
+
for i in range(_anim.get_track_count()):
|
| 122 |
+
if _anim.track_get_path(i) == full:
|
| 123 |
+
return i
|
| 124 |
+
var type := Animation.TYPE_VALUE
|
| 125 |
+
if value is Transform3D:
|
| 126 |
+
type = Animation.TYPE_VALUE
|
| 127 |
+
_anim.add_track(type)
|
| 128 |
+
var idx := _anim.get_track_count() - 1
|
| 129 |
+
_anim.track_set_path(idx, full)
|
| 130 |
+
if value is Vector3:
|
| 131 |
+
_anim.track_set_interpolation_type(idx, Animation.INTERPOLATION_CUBIC)
|
| 132 |
+
return idx
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
## Convenience: key every transform channel of a bone of a Skeleton3D.
|
| 136 |
+
## Godot stores bone poses as 3D transforms, so we key position/rotation/scale.
|
| 137 |
+
func add_bone_keyframe(skeleton: Skeleton3D, bone_name: String, time: float) -> void:
|
| 138 |
+
if skeleton == null:
|
| 139 |
+
return
|
| 140 |
+
var bone_idx := skeleton.find_bone(bone_name)
|
| 141 |
+
if bone_idx < 0:
|
| 142 |
+
push_warning("Timeline: bone '%s' not found" % bone_name)
|
| 143 |
+
return
|
| 144 |
+
var skin_path := "%s:%s" % [_player.get_path_to(skeleton), bone_name]
|
| 145 |
+
var pose := skeleton.get_bone_pose(bone_idx)
|
| 146 |
+
add_keyframe(skeleton, "bones/" + bone_name + "/position", time, pose.origin)
|
| 147 |
+
# Store rotation as quaternion for clean interpolation.
|
| 148 |
+
add_keyframe(skeleton, "bones/" + bone_name + "/rotation", time, pose.basis.get_rotation_quaternion())
|
| 149 |
+
add_keyframe(skeleton, "bones/" + bone_name + "/scale", time, pose.basis.get_scale())
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
func delete_keyframe(track: int, key: int) -> void:
|
| 153 |
+
if track >= 0 and track < _anim.get_track_count():
|
| 154 |
+
_anim.track_remove_key(track, key)
|
| 155 |
+
_key_count = maxi(0, _key_count - 1)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
func track_count() -> int:
|
| 159 |
+
return _anim.get_track_count() if _anim != null else 0
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
func key_count() -> int:
|
| 163 |
+
return _key_count
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
func set_duration(seconds: float) -> void:
|
| 167 |
+
duration = maxf(0.1, seconds)
|
| 168 |
+
_ensure_player()
|
| 169 |
+
_anim.length = duration
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
func _on_finished() -> void:
|
| 173 |
+
_playing = false
|
| 174 |
+
StudioBus.playback_state_changed.emit(false)
|
scripts/modules/timeline.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://rfbjygv1at48
|
scripts/studio/studio_main.gd
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node3D
|
| 2 |
+
##
|
| 3 |
+
## StudioMain - root bootstrap for MonkeyStudio.
|
| 4 |
+
##
|
| 5 |
+
## Builds the runtime scene tree in code so the whole app is one auditable
|
| 6 |
+
## script rather than a fragile binary .tscn. Scene tree created:
|
| 7 |
+
##
|
| 8 |
+
## StudioMain (Node3D)
|
| 9 |
+
## ├── WorldEnvironment (mobile renderer settings)
|
| 10 |
+
## ├── Sun (DirectionalLight3D)
|
| 11 |
+
## ├── Camera3D (touch_camera.gd)
|
| 12 |
+
## ├── Geometry (Node3D) <- user-created meshes live here
|
| 13 |
+
## ├── Rig (Node3D) <- Skeleton3D lives here
|
| 14 |
+
## ├── Features (Node) <- lazily-instantiated feature modules
|
| 15 |
+
## └── UI (CanvasLayer)
|
| 16 |
+
## └── (built by studio_ui.gd)
|
| 17 |
+
##
|
| 18 |
+
|
| 19 |
+
const TouchCameraScript := preload("res://scripts/studio/touch_camera.gd")
|
| 20 |
+
const StudioUIScript := preload("res://scripts/studio/studio_ui.gd")
|
| 21 |
+
|
| 22 |
+
var camera: Camera3D
|
| 23 |
+
var geometry: Node3D
|
| 24 |
+
var rig: Node3D
|
| 25 |
+
var feature_host: Node
|
| 26 |
+
var ui: CanvasLayer
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
func _ready() -> void:
|
| 30 |
+
_build_environment()
|
| 31 |
+
_build_lighting()
|
| 32 |
+
_build_camera()
|
| 33 |
+
geometry = _make_node("Geometry")
|
| 34 |
+
rig = _make_node("Rig")
|
| 35 |
+
feature_host = Node.new()
|
| 36 |
+
feature_host.name = "Features"
|
| 37 |
+
add_child(feature_host)
|
| 38 |
+
FeatureRegistry.host = feature_host
|
| 39 |
+
_build_ui()
|
| 40 |
+
_spawn_starter_scene()
|
| 41 |
+
_connect_signals()
|
| 42 |
+
print("[StudioMain] ready. %d features available." % FeatureRegistry.feature_count())
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
func _build_environment() -> void:
|
| 46 |
+
var env := Environment.new()
|
| 47 |
+
env.background_mode = Environment.BG_COLOR
|
| 48 |
+
env.background_color = Color(0.08, 0.09, 0.11)
|
| 49 |
+
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
| 50 |
+
env.ambient_light_color = Color(0.35, 0.38, 0.45)
|
| 51 |
+
env.ambient_light_energy = 0.6
|
| 52 |
+
# Mobile-friendly tonemap + no heavy SSAO/GI by default.
|
| 53 |
+
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
| 54 |
+
env.ssao_enabled = false
|
| 55 |
+
env.glow_enabled = false
|
| 56 |
+
var we := WorldEnvironment.new()
|
| 57 |
+
we.name = "WorldEnvironment"
|
| 58 |
+
we.environment = env
|
| 59 |
+
add_child(we)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
func _build_lighting() -> void:
|
| 63 |
+
var sun := DirectionalLight3D.new()
|
| 64 |
+
sun.name = "Sun"
|
| 65 |
+
sun.rotation_degrees = Vector3(-50, -40, 0)
|
| 66 |
+
sun.light_energy = 1.1
|
| 67 |
+
sun.shadow_enabled = true
|
| 68 |
+
# Lower shadow resolution on mobile to save bandwidth.
|
| 69 |
+
sun.directional_shadow_max_distance = 40.0
|
| 70 |
+
add_child(sun)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
func _build_camera() -> void:
|
| 74 |
+
camera = TouchCameraScript.new()
|
| 75 |
+
camera.name = "Camera3D"
|
| 76 |
+
camera.fov = 60.0
|
| 77 |
+
camera.position = Vector3(4, 4, 8)
|
| 78 |
+
add_child(camera)
|
| 79 |
+
camera.focus = Vector3.ZERO
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
func _make_node(n: String) -> Node3D:
|
| 83 |
+
var node := Node3D.new()
|
| 84 |
+
node.name = n
|
| 85 |
+
add_child(node)
|
| 86 |
+
return node
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
func _build_ui() -> void:
|
| 90 |
+
ui = StudioUIScript.new()
|
| 91 |
+
ui.name = "UI"
|
| 92 |
+
add_child(ui)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
## Spawn a cube so the app is immediately interactive on first launch.
|
| 96 |
+
func _spawn_starter_scene() -> void:
|
| 97 |
+
FeatureRegistry.activate("mesh.cube")
|
| 98 |
+
var gen: Node = feature_host.get_node_or_null("Feature_mesh.cube")
|
| 99 |
+
if gen != null and gen.has_method("spawn"):
|
| 100 |
+
var mesh: MeshInstance3D = gen.call("spawn", "cube", {"size": 1.5})
|
| 101 |
+
_ensure_collision(mesh)
|
| 102 |
+
StudioBus.object_selected.emit(mesh)
|
| 103 |
+
if camera != null:
|
| 104 |
+
camera.frame_aabb(mesh.mesh.get_aabb())
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
## Give a mesh a static trimesh collider so sculpting raycasts hit it.
|
| 108 |
+
func _ensure_collision(mi: MeshInstance3D) -> void:
|
| 109 |
+
if mi == null or mi.mesh == null:
|
| 110 |
+
return
|
| 111 |
+
var body := StaticBody3D.new()
|
| 112 |
+
body.name = mi.name + "_Body"
|
| 113 |
+
var shape := CollisionShape3D.new()
|
| 114 |
+
var trimesh := mi.mesh.create_trimesh_shape()
|
| 115 |
+
if trimesh != null:
|
| 116 |
+
shape.shape = trimesh
|
| 117 |
+
else:
|
| 118 |
+
# Fallback to a box for exotic primitives without trimesh support.
|
| 119 |
+
var box := BoxShape3D.new()
|
| 120 |
+
box.size = mi.mesh.get_aabb().size
|
| 121 |
+
shape.shape = box
|
| 122 |
+
body.add_child(shape)
|
| 123 |
+
mi.add_child(body)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
func _connect_signals() -> void:
|
| 127 |
+
StudioBus.fps_throttled.connect(_on_fps_throttled)
|
| 128 |
+
MemoryManager.memory_warning.connect(_on_memory_warning)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
func _on_fps_throttled(fps: int) -> void:
|
| 132 |
+
# Shed load: disable shadows first, then reduce the sim substep budget.
|
| 133 |
+
var sun := get_node_or_null("Sun") as DirectionalLight3D
|
| 134 |
+
if sun != null and sun.shadow_enabled:
|
| 135 |
+
sun.shadow_enabled = false
|
| 136 |
+
print("[Perf] shadows off at %d fps" % fps)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
func _on_memory_warning(used_mb: float, budget_mb: float) -> void:
|
| 140 |
+
print("[Mem] warning: %.0f/%.0f MB" % [used_mb, budget_mb])
|
scripts/studio/studio_main.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://ds0chccxfkthr
|
scripts/studio/studio_ui.gd
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends CanvasLayer
|
| 2 |
+
##
|
| 3 |
+
## StudioUI - dark material theme + floating, collapsible panels (Step 4).
|
| 4 |
+
##
|
| 5 |
+
## Layout (all built in code, resolution independent, scaled by a UI_SCALE
|
| 6 |
+
## constant so it fits phones in portrait/landscape and tablets):
|
| 7 |
+
##
|
| 8 |
+
## TopBar : project name, memory HUD, undo/redo
|
| 9 |
+
## LeftDock : tool rail (mesh / sculpt / rig / sim / anim)
|
| 10 |
+
## RightPanels : collapsible panels -> Tools, Layers, Timeline
|
| 11 |
+
## BottomBar : play/pause + timeline scrubber
|
| 12 |
+
##
|
| 13 |
+
## Touch targets are >= 48px. The theme is Material-ish dark to match the
|
| 14 |
+
## viewport and reduce OLED power draw on phones.
|
| 15 |
+
##
|
| 16 |
+
|
| 17 |
+
const UI_SCALE := 1.0
|
| 18 |
+
const TOUCH_MIN := 48.0
|
| 19 |
+
|
| 20 |
+
var _touch_camera: Camera3D = null
|
| 21 |
+
var _ui_root: Control
|
| 22 |
+
var _panels: Dictionary = {} # panel_id -> PanelContainer
|
| 23 |
+
var _tool_buttons: Dictionary = {}
|
| 24 |
+
var _memory_label: Label
|
| 25 |
+
var _fps_label: Label
|
| 26 |
+
var _timeline: HSlider
|
| 27 |
+
var _play_button: Button
|
| 28 |
+
var _status: Label
|
| 29 |
+
|
| 30 |
+
var _active_brush := "sculpt.draw"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
func _ready() -> void:
|
| 34 |
+
layer = 10
|
| 35 |
+
_touch_camera = get_parent().get_node_or_null("Camera3D") as Camera3D
|
| 36 |
+
_build_theme()
|
| 37 |
+
_build_root()
|
| 38 |
+
_build_top_bar()
|
| 39 |
+
_build_tool_rail()
|
| 40 |
+
_build_panels()
|
| 41 |
+
_build_bottom_bar()
|
| 42 |
+
_connect_bus()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
## --- Theme ------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
func _build_theme() -> void:
|
| 48 |
+
var theme := Theme.new()
|
| 49 |
+
theme.default_font_size = int(16 * UI_SCALE)
|
| 50 |
+
|
| 51 |
+
var panel := StyleBoxFlat.new()
|
| 52 |
+
panel.bg_color = Color(0.11, 0.12, 0.15, 0.94)
|
| 53 |
+
panel.corner_radius_top_left = 10
|
| 54 |
+
panel.corner_radius_top_right = 10
|
| 55 |
+
panel.corner_radius_bottom_left = 10
|
| 56 |
+
panel.corner_radius_bottom_right = 10
|
| 57 |
+
panel.content_margin_left = 10
|
| 58 |
+
panel.content_margin_right = 10
|
| 59 |
+
panel.content_margin_top = 8
|
| 60 |
+
panel.content_margin_bottom = 8
|
| 61 |
+
theme.set_stylebox("panel", "PanelContainer", panel)
|
| 62 |
+
|
| 63 |
+
var btn := StyleBoxFlat.new()
|
| 64 |
+
btn.bg_color = Color(0.18, 0.2, 0.25)
|
| 65 |
+
btn.corner_radius_top_left = 8
|
| 66 |
+
btn.corner_radius_top_right = 8
|
| 67 |
+
btn.corner_radius_bottom_left = 8
|
| 68 |
+
btn.corner_radius_bottom_right = 8
|
| 69 |
+
btn.content_margin_left = 12
|
| 70 |
+
btn.content_margin_right = 12
|
| 71 |
+
btn.content_margin_top = 8
|
| 72 |
+
btn.content_margin_bottom = 8
|
| 73 |
+
theme.set_stylebox("normal", "Button", btn)
|
| 74 |
+
|
| 75 |
+
var btn_hover := btn.duplicate() as StyleBoxFlat
|
| 76 |
+
btn_hover.bg_color = Color(0.24, 0.27, 0.33)
|
| 77 |
+
theme.set_stylebox("hover", "Button", btn_hover)
|
| 78 |
+
|
| 79 |
+
var btn_pressed := btn.duplicate() as StyleBoxFlat
|
| 80 |
+
btn_pressed.bg_color = Color(0.31, 0.55, 0.86)
|
| 81 |
+
theme.set_stylebox("pressed", "Button", btn_pressed)
|
| 82 |
+
|
| 83 |
+
theme.set_color("font_color", "Label", Color(0.88, 0.9, 0.94))
|
| 84 |
+
theme.set_color("font_color", "Button", Color(0.88, 0.9, 0.94))
|
| 85 |
+
|
| 86 |
+
_ui_root_theme = theme
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
var _ui_root_theme: Theme
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
## --- Layout -----------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
func _build_root() -> void:
|
| 95 |
+
var root := Control.new()
|
| 96 |
+
root.name = "Root"
|
| 97 |
+
root.theme = _ui_root_theme
|
| 98 |
+
root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
| 99 |
+
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
| 100 |
+
add_child(root)
|
| 101 |
+
_ui_root = root
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
func _build_top_bar() -> void:
|
| 105 |
+
var bar := PanelContainer.new()
|
| 106 |
+
bar.name = "TopBar"
|
| 107 |
+
bar.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
| 108 |
+
bar.custom_minimum_size = Vector2(0, 52 * UI_SCALE)
|
| 109 |
+
bar.mouse_filter = Control.MOUSE_FILTER_STOP
|
| 110 |
+
_ui_root.add_child(bar)
|
| 111 |
+
|
| 112 |
+
var hbox := HBoxContainer.new()
|
| 113 |
+
hbox.add_theme_constant_override("separation", 10)
|
| 114 |
+
bar.add_child(hbox)
|
| 115 |
+
|
| 116 |
+
var title := Label.new()
|
| 117 |
+
title.text = " MonkeyStudio"
|
| 118 |
+
title.add_theme_font_size_override("font_size", int(20 * UI_SCALE))
|
| 119 |
+
hbox.add_child(title)
|
| 120 |
+
|
| 121 |
+
var spacer := Control.new()
|
| 122 |
+
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
| 123 |
+
hbox.add_child(spacer)
|
| 124 |
+
|
| 125 |
+
_fps_label = Label.new()
|
| 126 |
+
_fps_label.text = "FPS --"
|
| 127 |
+
hbox.add_child(_fps_label)
|
| 128 |
+
|
| 129 |
+
_memory_label = Label.new()
|
| 130 |
+
_memory_label.text = "MEM --"
|
| 131 |
+
hbox.add_child(_memory_label)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
func _build_tool_rail() -> void:
|
| 135 |
+
var rail := PanelContainer.new()
|
| 136 |
+
rail.name = "LeftDock"
|
| 137 |
+
rail.set_anchors_preset(Control.PRESET_CENTER_LEFT)
|
| 138 |
+
rail.position = Vector2(8, 0)
|
| 139 |
+
rail.mouse_filter = Control.MOUSE_FILTER_STOP
|
| 140 |
+
_ui_root.add_child(rail)
|
| 141 |
+
|
| 142 |
+
var vbox := VBoxContainer.new()
|
| 143 |
+
vbox.add_theme_constant_override("separation", 8)
|
| 144 |
+
rail.add_child(vbox)
|
| 145 |
+
|
| 146 |
+
var tools := [
|
| 147 |
+
["mesh", "Mesh", "mesh.cube"],
|
| 148 |
+
["sculpt", "Sculpt", "sculpt.draw"],
|
| 149 |
+
["rig", "Rig", "rig.skeleton"],
|
| 150 |
+
["sim", "Sim", "sim.cloth"],
|
| 151 |
+
["anim", "Anim", "anim.timeline"],
|
| 152 |
+
]
|
| 153 |
+
for t in tools:
|
| 154 |
+
var b := Button.new()
|
| 155 |
+
b.text = t[1]
|
| 156 |
+
b.custom_minimum_size = Vector2(96 * UI_SCALE, TOUCH_MIN * UI_SCALE)
|
| 157 |
+
b.pressed.connect(_on_tool_pressed.bind(t[2]))
|
| 158 |
+
vbox.add_child(b)
|
| 159 |
+
_tool_buttons[t[0]] = b
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
func _build_panels() -> void:
|
| 163 |
+
var stack := VBoxContainer.new()
|
| 164 |
+
stack.name = "RightPanels"
|
| 165 |
+
stack.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
| 166 |
+
stack.position = Vector2(-8, 60)
|
| 167 |
+
stack.add_theme_constant_override("separation", 8)
|
| 168 |
+
_ui_root.add_child(stack)
|
| 169 |
+
|
| 170 |
+
_add_panel(stack, "tools", "Tools", _build_tools_panel())
|
| 171 |
+
_add_panel(stack, "layers", "Layers", _build_layers_panel())
|
| 172 |
+
_add_panel(stack, "timeline", "Timeline", _build_timeline_panel())
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
func _add_panel(parent: Control, id: String, title: String, content: Control) -> void:
|
| 176 |
+
var panel := PanelContainer.new()
|
| 177 |
+
panel.name = "Panel_" + id
|
| 178 |
+
panel.custom_minimum_size = Vector2(220 * UI_SCALE, 0)
|
| 179 |
+
parent.add_child(panel)
|
| 180 |
+
|
| 181 |
+
var vbox := VBoxContainer.new()
|
| 182 |
+
panel.add_child(vbox)
|
| 183 |
+
|
| 184 |
+
var header := Button.new()
|
| 185 |
+
header.text = "v " + title
|
| 186 |
+
header.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
| 187 |
+
header.custom_minimum_size = Vector2(0, TOUCH_MIN * UI_SCALE * 0.8)
|
| 188 |
+
header.pressed.connect(_toggle_panel.bind(id))
|
| 189 |
+
vbox.add_child(header)
|
| 190 |
+
|
| 191 |
+
var body := MarginContainer.new()
|
| 192 |
+
body.name = "Body"
|
| 193 |
+
body.add_child(content)
|
| 194 |
+
vbox.add_child(body)
|
| 195 |
+
_panels[id] = panel
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
func _build_tools_panel() -> Control:
|
| 199 |
+
var box := VBoxContainer.new()
|
| 200 |
+
box.add_theme_constant_override("separation", 6)
|
| 201 |
+
var label := Label.new()
|
| 202 |
+
label.text = "Brush"
|
| 203 |
+
box.add_child(label)
|
| 204 |
+
for b in ["draw", "grab", "pinch", "smooth", "flatten"]:
|
| 205 |
+
var btn := Button.new()
|
| 206 |
+
btn.text = b.capitalize()
|
| 207 |
+
btn.custom_minimum_size = Vector2(0, TOUCH_MIN * UI_SCALE)
|
| 208 |
+
btn.pressed.connect(_on_brush_selected.bind("sculpt." + b))
|
| 209 |
+
box.add_child(btn)
|
| 210 |
+
box.add_child(_make_slider("Radius", 0.05, 2.0, 0.5, func(v): StudioBus.brush_radius_changed.emit(v)))
|
| 211 |
+
box.add_child(_make_slider("Strength", 0.05, 1.0, 0.5, func(v): StudioBus.brush_strength_changed.emit(v)))
|
| 212 |
+
return box
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
func _build_layers_panel() -> Control:
|
| 216 |
+
var box := VBoxContainer.new()
|
| 217 |
+
var label := Label.new()
|
| 218 |
+
label.text = "Scene objects appear here."
|
| 219 |
+
box.add_child(label)
|
| 220 |
+
return box
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
func _build_timeline_panel() -> Control:
|
| 224 |
+
var box := VBoxContainer.new()
|
| 225 |
+
var label := Label.new()
|
| 226 |
+
label.text = "Keyframe the selected object at the playhead."
|
| 227 |
+
box.add_child(label)
|
| 228 |
+
var key_btn := Button.new()
|
| 229 |
+
key_btn.text = "Add Keyframe"
|
| 230 |
+
key_btn.custom_minimum_size = Vector2(0, TOUCH_MIN * UI_SCALE)
|
| 231 |
+
key_btn.pressed.connect(_on_add_keyframe)
|
| 232 |
+
box.add_child(key_btn)
|
| 233 |
+
return box
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
func _make_slider(label_text: String, mn: float, mx: float, val: float, on_change: Callable) -> Control:
|
| 237 |
+
var wrap := VBoxContainer.new()
|
| 238 |
+
var l := Label.new()
|
| 239 |
+
l.text = label_text
|
| 240 |
+
wrap.add_child(l)
|
| 241 |
+
var s := HSlider.new()
|
| 242 |
+
s.min_value = mn
|
| 243 |
+
s.max_value = mx
|
| 244 |
+
s.step = 0.01
|
| 245 |
+
s.value = val
|
| 246 |
+
s.custom_minimum_size = Vector2(180 * UI_SCALE, TOUCH_MIN * UI_SCALE)
|
| 247 |
+
s.value_changed.connect(on_change)
|
| 248 |
+
wrap.add_child(s)
|
| 249 |
+
return wrap
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
func _build_bottom_bar() -> void:
|
| 253 |
+
var bar := PanelContainer.new()
|
| 254 |
+
bar.name = "BottomBar"
|
| 255 |
+
bar.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
| 256 |
+
bar.custom_minimum_size = Vector2(0, 56 * UI_SCALE)
|
| 257 |
+
bar.mouse_filter = Control.MOUSE_FILTER_STOP
|
| 258 |
+
_ui_root.add_child(bar)
|
| 259 |
+
|
| 260 |
+
var hbox := HBoxContainer.new()
|
| 261 |
+
hbox.add_theme_constant_override("separation", 10)
|
| 262 |
+
bar.add_child(hbox)
|
| 263 |
+
|
| 264 |
+
_play_button = Button.new()
|
| 265 |
+
_play_button.text = "Play"
|
| 266 |
+
_play_button.custom_minimum_size = Vector2(84 * UI_SCALE, TOUCH_MIN * UI_SCALE)
|
| 267 |
+
_play_button.pressed.connect(_on_play_pressed)
|
| 268 |
+
hbox.add_child(_play_button)
|
| 269 |
+
|
| 270 |
+
_timeline = HSlider.new()
|
| 271 |
+
_timeline.min_value = 0.0
|
| 272 |
+
_timeline.max_value = 5.0
|
| 273 |
+
_timeline.step = 0.01
|
| 274 |
+
_timeline.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
| 275 |
+
_timeline.custom_minimum_size = Vector2(0, TOUCH_MIN * UI_SCALE)
|
| 276 |
+
_timeline.value_changed.connect(_on_timeline_scrub)
|
| 277 |
+
hbox.add_child(_timeline)
|
| 278 |
+
|
| 279 |
+
_status = Label.new()
|
| 280 |
+
_status.text = "0.00s"
|
| 281 |
+
hbox.add_child(_status)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
## --- Behaviour --------------------------------------------------------------
|
| 285 |
+
|
| 286 |
+
func _connect_bus() -> void:
|
| 287 |
+
StudioBus.timeline_playhead.connect(_on_playhead)
|
| 288 |
+
StudioBus.playback_state_changed.connect(_on_playback_state)
|
| 289 |
+
StudioBus.brush_changed.connect(_on_brush_changed)
|
| 290 |
+
MemoryManager.memory_warning.connect(func(_u, _b): _refresh_hud())
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
func _process(_delta: float) -> void:
|
| 294 |
+
_fps_label.text = "FPS %d" % Engine.get_frames_per_second()
|
| 295 |
+
_refresh_hud()
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
func _refresh_hud() -> void:
|
| 299 |
+
var used: float = MemoryManager.get_loaded_mb()
|
| 300 |
+
_memory_label.text = "MEM %.0f/%.0f MB" % [used, MemoryManager.asset_budget_mb]
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
func _toggle_panel(id: String) -> void:
|
| 304 |
+
var panel: PanelContainer = _panels.get(id)
|
| 305 |
+
if panel == null:
|
| 306 |
+
return
|
| 307 |
+
var body := panel.get_node_or_null("VBoxContainer/Body")
|
| 308 |
+
# PanelContainer -> VBox -> [Button, MarginContainer]
|
| 309 |
+
var vbox := panel.get_child(0)
|
| 310 |
+
var body_node := vbox.get_node_or_null("Body") as Control
|
| 311 |
+
if body_node != null:
|
| 312 |
+
body_node.visible = not body_node.visible
|
| 313 |
+
StudioBus.panel_toggled.emit(id, body_node != null and body_node.visible)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
func _on_tool_pressed(feature_id: String) -> void:
|
| 317 |
+
# Turning on a sculpt brush means the finger paints; navigation uses 2 fingers.
|
| 318 |
+
var is_sculpt := feature_id.begins_with("sculpt.")
|
| 319 |
+
if _touch_camera != null:
|
| 320 |
+
_touch_camera.set("one_finger_orbit", not is_sculpt)
|
| 321 |
+
if is_sculpt:
|
| 322 |
+
var ok := FeatureRegistry.activate("sculpt.draw")
|
| 323 |
+
if ok:
|
| 324 |
+
# Ensure the sculpt module is wired to the selected object.
|
| 325 |
+
var sculpt: Node = find_sculpt_module()
|
| 326 |
+
if sculpt != null and StudioBus.has_signal("brush_changed"):
|
| 327 |
+
StudioBus.brush_changed.emit(feature_id)
|
| 328 |
+
_activate_feature_family(feature_id)
|
| 329 |
+
_set_status("Tool: " + feature_id)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
func find_sculpt_module() -> Node:
|
| 333 |
+
var host := get_parent().get_node_or_null("Features")
|
| 334 |
+
if host == null:
|
| 335 |
+
return null
|
| 336 |
+
return host.get_node_or_null("Feature_sculpt.draw")
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
func _activate_feature_family(feature_id: String) -> void:
|
| 340 |
+
match feature_id:
|
| 341 |
+
"mesh.cube": FeatureRegistry.activate("mesh.cube")
|
| 342 |
+
"rig.skeleton": FeatureRegistry.activate("rig.skeleton")
|
| 343 |
+
"sim.cloth":
|
| 344 |
+
FeatureRegistry.activate("sim.cloth")
|
| 345 |
+
StudioBus.sim_toggled.emit("sim.cloth", true)
|
| 346 |
+
"anim.timeline": FeatureRegistry.activate("anim.timeline")
|
| 347 |
+
_: pass
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
func _on_brush_selected(id: String) -> void:
|
| 351 |
+
_active_brush = id
|
| 352 |
+
StudioBus.brush_changed.emit(id)
|
| 353 |
+
_set_status("Brush: " + id)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
func _on_brush_changed(id: String) -> void:
|
| 357 |
+
_set_status("Brush: " + id)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
func _on_play_pressed() -> void:
|
| 361 |
+
var tl := _get_timeline_module()
|
| 362 |
+
if tl == null:
|
| 363 |
+
return
|
| 364 |
+
if tl.call("is_playing"):
|
| 365 |
+
tl.call("pause")
|
| 366 |
+
else:
|
| 367 |
+
tl.call("play")
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
func _on_timeline_scrub(value: float) -> void:
|
| 371 |
+
var tl := _get_timeline_module()
|
| 372 |
+
if tl != null:
|
| 373 |
+
tl.call("scrub", value)
|
| 374 |
+
_status.text = "%.2fs" % value
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
func _on_playhead(t: float) -> void:
|
| 378 |
+
_timeline.set_value_no_signal(t)
|
| 379 |
+
_status.text = "%.2fs" % t
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
func _on_playback_state(playing: bool) -> void:
|
| 383 |
+
_play_button.text = "Pause" if playing else "Play"
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
func _on_add_keyframe() -> void:
|
| 387 |
+
var tl := _get_timeline_module()
|
| 388 |
+
if tl == null:
|
| 389 |
+
FeatureRegistry.activate("anim.timeline")
|
| 390 |
+
tl = _get_timeline_module()
|
| 391 |
+
if tl == null:
|
| 392 |
+
return
|
| 393 |
+
var selected := _selected_object()
|
| 394 |
+
if selected != null:
|
| 395 |
+
tl.call("add_keyframe", selected, "position", _timeline.value, selected.position)
|
| 396 |
+
_set_status("Keyframe added")
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
func _get_timeline_module() -> Node:
|
| 400 |
+
var host := get_parent().get_node_or_null("Features")
|
| 401 |
+
if host == null:
|
| 402 |
+
return null
|
| 403 |
+
return host.get_node_or_null("Feature_anim.timeline")
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
func _selected_object() -> Node3D:
|
| 407 |
+
var geom := get_parent().get_node_or_null("Geometry")
|
| 408 |
+
if geom == null:
|
| 409 |
+
return null
|
| 410 |
+
for child in geom.get_children():
|
| 411 |
+
if child is MeshInstance3D:
|
| 412 |
+
return child
|
| 413 |
+
return null
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
func _set_status(text: String) -> void:
|
| 417 |
+
if _status != null:
|
| 418 |
+
_status.text = text
|
scripts/studio/studio_ui.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://brs4dwcg1h1qd
|
scripts/studio/touch_camera.gd
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Camera3D
|
| 2 |
+
##
|
| 3 |
+
## TouchCamera - lag-free mobile navigation for the 3D viewport (Step 3.1).
|
| 4 |
+
##
|
| 5 |
+
## Gesture map (configurable):
|
| 6 |
+
## one finger drag -> orbit (rotate around the focus point)
|
| 7 |
+
## two finger pinch -> dolly zoom
|
| 8 |
+
## two finger drag -> pan (move the focus point in screen space)
|
| 9 |
+
## three finger drag -> pan (alternative, larger screens)
|
| 10 |
+
##
|
| 11 |
+
## Design: raw InputEvent handling (not _input echo), one gesture re-evaluated
|
| 12 |
+
## per frame, no allocations in the hot path. Values are smoothed with an
|
| 13 |
+
## exponential filter so motion feels fluid even when touch sampling is jittery.
|
| 14 |
+
##
|
| 15 |
+
|
| 16 |
+
@export var orbit_speed: float = 0.4
|
| 17 |
+
@export var pan_speed: float = 0.6
|
| 18 |
+
@export var zoom_speed: float = 1.2
|
| 19 |
+
@export var min_distance: float = 0.5
|
| 20 |
+
@export var max_distance: float = 200.0
|
| 21 |
+
@export var smoothing: float = 18.0 # higher = snappier, lower = floatier
|
| 22 |
+
|
| 23 |
+
var focus: Vector3 = Vector3.ZERO
|
| 24 |
+
var _yaw: float = 0.6
|
| 25 |
+
var _pitch: float = -0.5
|
| 26 |
+
var _distance: float = 8.0
|
| 27 |
+
|
| 28 |
+
## When false, a single finger is reserved for sculpting/painting and only
|
| 29 |
+
## multi-finger gestures navigate. The UI flips this when a sculpt tool is on.
|
| 30 |
+
var one_finger_orbit: bool = true
|
| 31 |
+
var _painting := false
|
| 32 |
+
var _last_paint: Vector2 = Vector2.ZERO
|
| 33 |
+
|
| 34 |
+
# Smoothed targets
|
| 35 |
+
var _t_yaw: float = 0.6
|
| 36 |
+
var _t_pitch: float = -0.5
|
| 37 |
+
var _t_distance: float = 8.0
|
| 38 |
+
var _t_focus: Vector3 = Vector3.ZERO
|
| 39 |
+
|
| 40 |
+
# Touch bookkeeping
|
| 41 |
+
var _touches: Dictionary = {} # index -> position
|
| 42 |
+
var _prev_centroid: Vector2 = Vector2.ZERO
|
| 43 |
+
var _prev_spread: float = 0.0
|
| 44 |
+
var _gesture_active := false
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
func _ready() -> void:
|
| 48 |
+
_t_yaw = _yaw
|
| 49 |
+
_t_pitch = _pitch
|
| 50 |
+
_t_distance = _distance
|
| 51 |
+
_t_focus = focus
|
| 52 |
+
_apply_transform()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
func _input(event: InputEvent) -> void:
|
| 56 |
+
if event is InputEventScreenTouch:
|
| 57 |
+
_handle_touch(event)
|
| 58 |
+
elif event is InputEventScreenDrag:
|
| 59 |
+
_handle_drag(event)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
func _handle_touch(e: InputEventScreenTouch) -> void:
|
| 63 |
+
if e.pressed:
|
| 64 |
+
_touches[e.index] = e.position
|
| 65 |
+
if e.index == 0 and not one_finger_orbit and _touches.size() == 1:
|
| 66 |
+
_painting = true
|
| 67 |
+
_last_paint = e.position
|
| 68 |
+
StudioBus.stroke_begin.emit(_ray_center(e.position))
|
| 69 |
+
else:
|
| 70 |
+
_touches.erase(e.index)
|
| 71 |
+
if e.index == 0 and _painting:
|
| 72 |
+
_painting = false
|
| 73 |
+
StudioBus.stroke_end.emit()
|
| 74 |
+
if _touches.size() >= 2:
|
| 75 |
+
_recompute_gesture_baseline()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
func _handle_drag(e: InputEventScreenDrag) -> void:
|
| 79 |
+
_touches[e.index] = e.position
|
| 80 |
+
if _painting and e.index == 0 and not one_finger_orbit:
|
| 81 |
+
var world := _ray_center(e.position)
|
| 82 |
+
var prev_world := _ray_center(_last_paint)
|
| 83 |
+
StudioBus.stroke_apply.emit(world, world - prev_world)
|
| 84 |
+
_last_paint = e.position
|
| 85 |
+
return
|
| 86 |
+
var n := _touches.size()
|
| 87 |
+
if n == 1:
|
| 88 |
+
# Orbit
|
| 89 |
+
var d := e.relative
|
| 90 |
+
_t_yaw -= d.x * orbit_speed * 0.01
|
| 91 |
+
_t_pitch = clampf(_t_pitch - d.y * orbit_speed * 0.01, -1.5, 1.5)
|
| 92 |
+
elif n == 2:
|
| 93 |
+
var pts := _touches.values()
|
| 94 |
+
var centroid: Vector2 = (pts[0] + pts[1]) * 0.5
|
| 95 |
+
var spread: float = (pts[0] as Vector2).distance_to(pts[1] as Vector2)
|
| 96 |
+
if _prev_spread > 0.0:
|
| 97 |
+
var dzoom := (spread - _prev_spread) * zoom_speed * 0.01
|
| 98 |
+
_t_distance = clampf(_t_distance - dzoom, min_distance, max_distance)
|
| 99 |
+
var dpan := centroid - _prev_centroid
|
| 100 |
+
_apply_pan(dpan)
|
| 101 |
+
_prev_centroid = centroid
|
| 102 |
+
_prev_spread = spread
|
| 103 |
+
elif n >= 3:
|
| 104 |
+
var pts := _touches.values()
|
| 105 |
+
var centroid := Vector2.ZERO
|
| 106 |
+
for p in pts:
|
| 107 |
+
centroid += p
|
| 108 |
+
centroid /= float(pts.size())
|
| 109 |
+
var dpan := centroid - _prev_centroid
|
| 110 |
+
_apply_pan(dpan)
|
| 111 |
+
_prev_centroid = centroid
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
func _recompute_gesture_baseline() -> void:
|
| 115 |
+
var pts := _touches.values()
|
| 116 |
+
if pts.size() >= 2:
|
| 117 |
+
_prev_centroid = (pts[0] + pts[1]) * 0.5
|
| 118 |
+
_prev_spread = (pts[0] as Vector2).distance_to(pts[1] as Vector2)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
func _apply_pan(screen_delta: Vector2) -> void:
|
| 122 |
+
# Pan along camera right/up, scaled by distance so it feels consistent at
|
| 123 |
+
# any zoom level.
|
| 124 |
+
var scale := _distance * pan_speed * 0.002
|
| 125 |
+
var right := global_transform.basis.x
|
| 126 |
+
var up := global_transform.basis.y
|
| 127 |
+
_t_focus -= right * screen_delta.x * scale
|
| 128 |
+
_t_focus += up * screen_delta.y * scale
|
| 129 |
+
focus = _t_focus
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
func _process(delta: float) -> void:
|
| 133 |
+
var t := 1.0 - exp(-smoothing * delta)
|
| 134 |
+
_yaw = lerpf(_yaw, _t_yaw, t)
|
| 135 |
+
_pitch = lerpf(_pitch, _t_pitch, t)
|
| 136 |
+
_distance = lerpf(_distance, _t_distance, t)
|
| 137 |
+
focus = focus.lerp(_t_focus, t)
|
| 138 |
+
_apply_transform()
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
func _apply_transform() -> void:
|
| 142 |
+
var offset := Vector3(
|
| 143 |
+
cos(_pitch) * sin(_yaw),
|
| 144 |
+
sin(_pitch),
|
| 145 |
+
cos(_pitch) * cos(_yaw)
|
| 146 |
+
) * _distance
|
| 147 |
+
global_position = focus + offset
|
| 148 |
+
look_at(focus, Vector3.UP)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
## --- Public helpers ---------------------------------------------------------
|
| 152 |
+
|
| 153 |
+
func frame_aabb(box: AABB, margin: float = 1.6) -> void:
|
| 154 |
+
_t_focus = box.get_center()
|
| 155 |
+
focus = _t_focus
|
| 156 |
+
var radius := maxf(0.5, box.size.length() * 0.5)
|
| 157 |
+
_t_distance = clampf(radius * margin * 2.0, min_distance, max_distance)
|
| 158 |
+
_distance = _t_distance
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
func set_focus(p: Vector3) -> void:
|
| 162 |
+
_t_focus = p
|
| 163 |
+
focus = p
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
func screen_to_ray(screen_pos: Vector2) -> Dictionary:
|
| 167 |
+
return {
|
| 168 |
+
"origin": project_ray_origin(screen_pos),
|
| 169 |
+
"dir": project_ray_normal(screen_pos),
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
## Ray-cast the viewport at a screen point against physics; returns the world
|
| 174 |
+
## hit (or a point in front of the camera if nothing is hit).
|
| 175 |
+
func _ray_center(screen_pos: Vector2) -> Vector3:
|
| 176 |
+
var origin := project_ray_origin(screen_pos)
|
| 177 |
+
var dir := project_ray_normal(screen_pos)
|
| 178 |
+
var space := get_world_3d().direct_space_state
|
| 179 |
+
var q := PhysicsRayQueryParameters3D.create(origin, origin + dir * 1000.0)
|
| 180 |
+
var res := space.intersect_ray(q)
|
| 181 |
+
if not res.is_empty():
|
| 182 |
+
return res["position"]
|
| 183 |
+
return origin + dir * _distance
|
scripts/studio/touch_camera.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://diopfaymyxcso
|
tests/self_test.gd
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
extends Node
|
| 2 |
+
##
|
| 3 |
+
## SelfTest - headless verification of every core subsystem.
|
| 4 |
+
## Run: godot --headless res://tests/self_test.tscn
|
| 5 |
+
##
|
| 6 |
+
## Exits with code 0 if all checks pass, 1 otherwise. This is the regression
|
| 7 |
+
## gate for the modules; it does not need a display or GPU.
|
| 8 |
+
##
|
| 9 |
+
|
| 10 |
+
var _failures := 0
|
| 11 |
+
var _host: Node3D
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
func _ready() -> void:
|
| 15 |
+
_host = Node3D.new()
|
| 16 |
+
_host.name = "TestHost"
|
| 17 |
+
var geom := Node3D.new()
|
| 18 |
+
geom.name = "Geometry"
|
| 19 |
+
_host.add_child(geom)
|
| 20 |
+
var rig := Node3D.new()
|
| 21 |
+
rig.name = "Rig"
|
| 22 |
+
_host.add_child(rig)
|
| 23 |
+
add_child(_host)
|
| 24 |
+
|
| 25 |
+
_test_memory_manager()
|
| 26 |
+
_test_mesh_generators()
|
| 27 |
+
_test_sculpt()
|
| 28 |
+
_test_rigging()
|
| 29 |
+
_test_simulation()
|
| 30 |
+
_test_timeline()
|
| 31 |
+
|
| 32 |
+
print("")
|
| 33 |
+
if _failures == 0:
|
| 34 |
+
print("SELFTEST: ALL PASS")
|
| 35 |
+
get_tree().quit(0)
|
| 36 |
+
else:
|
| 37 |
+
print("SELFTEST: %d FAILURE(S)" % _failures)
|
| 38 |
+
get_tree().quit(1)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
func _check(name: String, cond: bool) -> void:
|
| 42 |
+
if cond:
|
| 43 |
+
print(" [PASS] ", name)
|
| 44 |
+
else:
|
| 45 |
+
_failures += 1
|
| 46 |
+
print(" [FAIL] ", name)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
func _test_memory_manager() -> void:
|
| 50 |
+
print("[Test] MemoryManager")
|
| 51 |
+
_check("budget set", MemoryManager.asset_budget_mb > 0.0)
|
| 52 |
+
_check("feature count >= 20", FeatureRegistry.feature_count() >= 20)
|
| 53 |
+
var snap := MemoryManager.debug_snapshot()
|
| 54 |
+
_check("snapshot has budget", snap.has("budget_mb"))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
func _load_module(path: String) -> Node:
|
| 58 |
+
var script: GDScript = load(path)
|
| 59 |
+
var n: Node = script.new()
|
| 60 |
+
_host.add_child(n)
|
| 61 |
+
if n.has_method("_activate"):
|
| 62 |
+
n.call("_activate", _host)
|
| 63 |
+
return n
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
func _test_mesh_generators() -> void:
|
| 67 |
+
print("[Test] Mesh generators")
|
| 68 |
+
var mg := _load_module("res://scripts/modules/mesh_gen.gd")
|
| 69 |
+
for kind in ["cube", "sphere", "cylinder", "plane", "torus", "ico", "suzanne"]:
|
| 70 |
+
var data: Dictionary = mg.call("generate", kind, {})
|
| 71 |
+
var vcount: int = (data["verts"] as PackedVector3Array).size()
|
| 72 |
+
var icount: int = (data["indices"] as PackedInt32Array).size()
|
| 73 |
+
_check("%s has verts & tris (v=%d,i=%d)" % [kind, vcount, icount], vcount > 0 and icount > 0)
|
| 74 |
+
var mi: MeshInstance3D = mg.call("spawn", "sphere", {"radius": 1.0})
|
| 75 |
+
_check("spawn returns MeshInstance3D", mi != null and mi.mesh != null and mi.mesh.get_surface_count() == 1)
|
| 76 |
+
var cost := VertexBufferManager.estimate_cost_mb(1000)
|
| 77 |
+
_check("cost estimate > 0", cost > 0.0)
|
| 78 |
+
mg.queue_free()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
func _test_sculpt() -> void:
|
| 82 |
+
print("[Test] Sculpting")
|
| 83 |
+
var mg := _load_module("res://scripts/modules/mesh_gen.gd")
|
| 84 |
+
var sc := _load_module("res://scripts/modules/sculpt.gd")
|
| 85 |
+
var mi: MeshInstance3D = mg.call("spawn", "sphere", {"radius": 1.0})
|
| 86 |
+
sc.call("set_target", mi)
|
| 87 |
+
var before: PackedVector3Array = (mi.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] as PackedVector3Array).duplicate()
|
| 88 |
+
for brush in ["draw", "grab", "pinch", "smooth", "flatten"]:
|
| 89 |
+
sc.set("_brush_id", brush)
|
| 90 |
+
sc.call("begin", Vector3(0, 1, 0))
|
| 91 |
+
sc.call("apply", Vector3(0, 1, 0), Vector3(0, 0.1, 0))
|
| 92 |
+
sc.call("apply", Vector3(0, 1, 0), Vector3(0.05, 0.1, 0))
|
| 93 |
+
sc.call("end")
|
| 94 |
+
var after: PackedVector3Array = mi.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
|
| 95 |
+
var changed := false
|
| 96 |
+
for i in range(mini(before.size(), after.size())):
|
| 97 |
+
if before[i].distance_to(after[i]) > 0.0001:
|
| 98 |
+
changed = true
|
| 99 |
+
break
|
| 100 |
+
_check("sculpt modified vertices", changed)
|
| 101 |
+
_check("mesh still valid after sculpt", mi.mesh.get_surface_count() == 1)
|
| 102 |
+
mg.queue_free()
|
| 103 |
+
sc.queue_free()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
func _test_rigging() -> void:
|
| 107 |
+
print("[Test] Rigging")
|
| 108 |
+
var mg := _load_module("res://scripts/modules/mesh_gen.gd")
|
| 109 |
+
var rg := _load_module("res://scripts/modules/rigging.gd")
|
| 110 |
+
var mi: MeshInstance3D = mg.call("spawn", "cube", {"size": 1.0})
|
| 111 |
+
rg.call("generate_default_armature", mi)
|
| 112 |
+
_check("bones created", int(rg.call("bone_count")) >= 9)
|
| 113 |
+
_check("mesh has skin", mi.skin != null)
|
| 114 |
+
_check("mesh has skeleton path", mi.skeleton != NodePath())
|
| 115 |
+
if mi.mesh.get_surface_count() > 0:
|
| 116 |
+
var arr: Array = mi.mesh.surface_get_arrays(0)
|
| 117 |
+
_check("bones channel present", arr[Mesh.ARRAY_BONES] != null)
|
| 118 |
+
_check("weights channel present", arr[Mesh.ARRAY_WEIGHTS] != null)
|
| 119 |
+
mg.queue_free()
|
| 120 |
+
rg.queue_free()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
func _test_simulation() -> void:
|
| 124 |
+
print("[Test] Simulation")
|
| 125 |
+
var sim := _load_module("res://scripts/modules/simulation.gd")
|
| 126 |
+
sim.call("create_cloth", "sim.cloth", 6, 6, 1.0)
|
| 127 |
+
var before: PackedVector3Array = (sim.get("_sims")["sim.cloth"]["particles"] as PackedVector3Array).duplicate()
|
| 128 |
+
for i in range(30):
|
| 129 |
+
sim.call("_step", sim.get("_sims")["sim.cloth"])
|
| 130 |
+
sim.call("_upload", sim.get("_sims")["sim.cloth"])
|
| 131 |
+
var after: PackedVector3Array = sim.get("_sims")["sim.cloth"]["particles"]
|
| 132 |
+
var moved := false
|
| 133 |
+
for i in range(after.size()):
|
| 134 |
+
if before[i].distance_to(after[i]) > 0.0001:
|
| 135 |
+
moved = true
|
| 136 |
+
break
|
| 137 |
+
_check("cloth fell under gravity", moved)
|
| 138 |
+
var mesh: ArrayMesh = sim.get("_sims")["sim.cloth"]["mesh"]
|
| 139 |
+
_check("cloth mesh uploaded", mesh.get_surface_count() == 1)
|
| 140 |
+
sim.call("create_hair", "sim.hair", 4, 5)
|
| 141 |
+
_check("hair sim created", int(sim.call("active_sim_count")) >= 2)
|
| 142 |
+
sim.queue_free()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
func _test_timeline() -> void:
|
| 146 |
+
print("[Test] Timeline")
|
| 147 |
+
var mg := _load_module("res://scripts/modules/mesh_gen.gd")
|
| 148 |
+
var tl := _load_module("res://scripts/modules/timeline.gd")
|
| 149 |
+
var mi: MeshInstance3D = mg.call("spawn", "cube", {"size": 1.0})
|
| 150 |
+
tl.call("add_keyframe", mi, "position", 0.0, Vector3.ZERO)
|
| 151 |
+
tl.call("add_keyframe", mi, "position", 1.0, Vector3(0, 2, 0))
|
| 152 |
+
_check("track created", int(tl.call("track_count")) >= 1)
|
| 153 |
+
_check("key count == 2", int(tl.call("key_count")) == 2)
|
| 154 |
+
tl.call("scrub", 0.5)
|
| 155 |
+
_check("scrub clamps/works", absf(float(tl.call("playhead")) - 0.5) < 0.001)
|
| 156 |
+
mg.queue_free()
|
| 157 |
+
tl.queue_free()
|
tests/self_test.gd.uid
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
uid://b0chtaj3gt8qr
|
tests/self_test.tscn
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[gd_scene load_steps=2 format=3]
|
| 2 |
+
|
| 3 |
+
[ext_resource type="Script" path="res://tests/self_test.gd" id="1_test"]
|
| 4 |
+
|
| 5 |
+
[node name="SelfTest" type="Node"]
|
| 6 |
+
script = ExtResource("1_test")
|