⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
blender-forest
Make an ultra-realistic 3D environment in Blender with Claude (a forest, a nature scene, a car driving through it) by following a long YouTube tutorial instead of writing a prompt - Claude watches the tutorial, writes the shot plan, checks the downloaded assets for anything unsafe, builds the world in stages with its own scatter system, rigs the car itself, measures the camera moves from the reference frames, benchmarks the render settings and renders overnight. Use when someone wants a realistic environment / forest / landscape / car-in-nature shot in Blender with Claude, wants to "recreate a tutorial scene", or asks for any single step (scatter, gravel path, car rig, render benchmark, clay preview).
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
Claude Blender Skills
Free Claude skills that make realistic Blender animations. Each one is the complete workflow behind one of my YouTube videos, with the real scripts that made the shot. Install a skill, tell Claude your idea, and it plans, builds, lights, animates and renders the scene in your Blender. You don't need to watch the videos first.
![]()
| Skill | What it makes | Video |
|---|---|---|
blender-film | 10 s night FPV drone shot through a city: storyboard, real physics path, night lighting, sound designed from the 3D motion, overnight render, white clay build-up reveal | How I Built This Insane Realistic Animation Using Claude in Blender |
blender-forest | Ultra-realistic forest with a car driving through it, built by following a long tutorial: own scatter system, own car rig, render benchmark | How to Make an Ultra-Realistic 3D Environment Using Claude |
blender-car-film | 20 s cinematic story film on a snowy night: real car model, set built in Python, snow particles, 6 hand-held cameras, render from 5-8 h down to 1 h | How I Built This 3D Short Film With Claude in Blender |
Contents
- What you need
- Install
- Quick start: your first shot
- How Claude controls Blender
- The skills in detail
- Rules that make it look real
- Render without losing a night
- Safety
- FAQ
- Credits
What you need
- Claude. Claude Code works best, because Claude can run the scripts and look at the renders itself. The workflows also work in a normal Claude chat: Claude writes the scripts, you run them.
- Blender 4.4 or newer (everything was made on Blender 5.2), free from blender.org.
- A GPU for Cycles. All three films were rendered on one NVIDIA RTX 5070 (12 GB). Smaller GPUs work, renders just take longer.
- Python 3 and ffmpeg on your PATH.
numpyandscipyfor the physics and sound steps ofblender-film. - Your own assets: a city or environment pack you own, or free CC0 assets from Poly Haven. No paid add-ons are needed.
Install
Claude Code (recommended)
Windows (PowerShell):
git clone https://github.com/mromidev/claude-blender-skills.git
New-Item -ItemType Directory -Force "$HOME\.claude\skills" | Out-Null
Copy-Item -Recurse -Force claude-blender-skills\skills\* "$HOME\.claude\skills\"
macOS / Linux:
git clone https://github.com/mromidev/claude-blender-skills.git
mkdir -p ~/.claude/skills
cp -r claude-blender-skills/skills/* ~/.claude/skills/
Restart Claude Code. The skills load by themselves when you ask for "a cinematic Blender shot", "a realistic forest in Blender", "a short film in Blender", or any single step ("night lighting for my city", "a clay build-up reveal").
Claude app (claude.ai / desktop)
If your Claude app lets you add custom skills, zip one skill folder (for example skills/blender-film) and upload it there.
Any chat, no install
Open a skill's SKILL.md, copy all of it into a new Claude chat, then describe your idea. Add the scripts you need from that skill's
scripts/ folder when Claude asks for them.
Quick start: your first shot
-
Install the skills (above).
-
Make a project folder, for example
~/blender-film, and open Claude Code in it. -
Open Blender. In the Scripting tab, open
tools/bridge.pyfrom theblender-filmskill and press Run Script. Blender now listens on127.0.0.1:9876(only your own PC can reach it). -
Paste this into Claude Code:
Use the blender-film skill. My idea: [a drone flying through a canyon at sunset / a car chase at night / ...]. My assets: [what you have, or "free Poly Haven assets"]. Start with step 1: write the storyboard and wait for my approval. -
Approve the storyboard, then let Claude go step by step. After each step it renders a small test still so you can check it. It asks before any long render.
How Claude controls Blender
Claude Code --python tools/send.py script.py--> bridge.py inside your open Blender (127.0.0.1:9876)
^ |
+---------------- output, errors, test renders <----------+
tools/bridge.pyruns inside Blender and executes the Python it receives on Blender's main thread.tools/send.pysends a script file (python tools/send.py scripts/06_night.py) or one line (python tools/send.py -c "print(bpy.data.filepath)") and prints what came back.- You watch every change happen in your own Blender window. Long renders run in background Blender (
blender -b) from a saved file.
The skills in detail
blender-film: night FPV drone shot

- Storyboard first. A 10-second shot list with timings, camera per shot, where the hero is. Turned into one image sheet.
- City. 5 buildings from an asset pack copied, stretched and recoloured into 173 instances. Cars, lamps, trees, hydrants.
- Motion from physics. A point-mass simulation (24 fps, 1/480 s steps, gravity, quadratic air drag, ropes that can't stretch) searches the anchor timings until the path threads the street. Path curve + empty on Follow Path, offset keyed every frame.
- Hero and the pivot. The first hero was a rigged Spider-Man with Mixamo motion capture. It looked stiff, because mocap is recorded on the ground. Swapped for an FPV drone on the same path: thrust axis = acceleration + gravity, nose along the velocity, throttle cuts where the path is ballistic, power flip, barrel roll, back flip, braking flare into a hover.
- Realism pass. AgX exposure, thin haze, asphalt wear, oil drip line, repair patches, dirt at the foot of every wall.
- Night. Practical lights only. Windows found from the facade roughness map (glass is smooth):
roughness < 0.3= glass, then a random subset lit warm, cool or off. LED street lamps at 3200 K, car lights, red signals, blue fog, wet asphalt. - Camera. Camera Shakify (real recorded shake) on every camera, a street-level lens 0.45 m above the wet road, bloom and slight barrel distortion, no colour grade.
- Sound from the motion. The animation is read every frame (distance, radial speed, side, throttle). Throttle drives motor pitch, radial speed drives Doppler, side drives the pan, cars pass at their closest approach, street-canyon reverb. -14 LUFS.
- Render. Tree LODs and hidden duplicates removed, six final-quality test stills first, only the frames of the edit rendered, resumable, with a progress panel. About 3.5 hours overnight. Then every frame is checked.
- Clay build-up. The whole city in white clay drops from the sky piece by piece (507 pieces, 7 s), then one wipe to night.

Full step list and all 20 scripts: skills/blender-film.
blender-forest: realistic environment from a tutorial

- Give Claude a long tutorial (1 h+, free assets) instead of a prompt: "We are going to recreate his scene exactly."
- Claude reads the transcript, captures the reference frames, writes
PLAN.mdwith every shot and stage. - Every downloaded asset is checked:
// HOW IT'S BUILT
// FILES
31 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/blender-forest/SKILL.md7.5 KBa24b954d514fupstream ↗
--- name: blender-forest description: Make an ultra-realistic 3D environment in Blender with Claude (a forest, a nature scene, a car driving through it) by following a long YouTube tutorial instead of writing a prompt - Claude watches the tutorial, writes the shot plan, checks the downloaded assets for anything unsafe, builds the world in stages with its own scatter system, rigs the car itself, measures the camera moves from the reference frames, benchmarks the render settings and renders overnight. Use when someone wants a realistic environment / forest / landscape / car-in-nature shot in Blender with Claude, wants to "recreate a tutorial scene", or asks for any single step (scatter, gravel path, car rig, render benchmark, clay preview). --- # Ultra-realistic environment from a tutorial The workflow behind a 13-second shot of a Porsche driving through a sunlit forest (2560x1440, 390 frames). The big idea: **don't ask Claude for a scene. Give it a long tutorial and let it follow the tutorial step by step.** Claude writes the code and places the cameras. The tutorial's free assets and open-source tools make the models. Every script in `scripts/` was actually run for that shot. Names inside them (`forest.blend`, his material and collection names) come from that scene - rename them for yours. ## Rules for Claude - Do not build 3D models or animation from scratch. Use the free assets that come with the tutorial, or open-source ones. - You are good at code and cameras: scripts, scatter systems, camera moves, render settings. Do those. - Write your own code. Do not install add-ons or run scripts you did not read. (The paid Geo-Scatter add-on and the Rig-A-Car add-on were both replaced by our own code.) - After every stage, render a small test still and put it next to the reference frame. Fix what is off before moving on. - Hand render-and-compare loops to a cheaper sub-agent (Sonnet) with a tight brief and read only its short report. Saves tokens. - Tell me how long a render will take before you start it. Keep the PC usable: below-normal priority, 8 threads, texture limit 2048 for tests. ## 0. Setup - Blender 5.x (made on 5.2), an NVIDIA GPU (OptiX), Python 3, `ffmpeg`. - Project folder `~/blender-forest` (the scripts use it as `ROOT`): `scene/forest.blend`, `assets/`, `research/`, `render/`. - Background runs: `blender -b scene/forest.blend --python scripts/<script>.py`. For a live session, use the bridge from the `blender-film` skill (`tools/bridge.py` + `tools/send.py`). ## 1. Pick the tutorial A long (1 h+) Blender environment tutorial that gives its assets away for free. Then say: "Go watch his video. We are going to recreate his scene exactly as he did." ## 2. Claude watches it - Download the transcript and read all of it. - Capture the first 30 s at 30 fps, build contact sheets, detect the cuts (`notes/camera_moves.md` is what that analysis looks like). - Write `PLAN.md`: every shot, its camera move and its length, plus the build stages (`notes/PLAN.md`). - YouTube may block command-line downloaders with a bot check. Then grab frames and the transcript through the browser. ## 3. Assets and safety - Download every asset, unpack each archive into its own folder. Run a virus scan on the downloads. - List each archive for `.exe .bat .ps1 .py .dll` before unpacking. - Open every .blend headless with `--disable-autoexec`. `scripts/audit_blend.py` counts embedded scripts, drivers and linked libraries: expect 0. `scripts/inspect_assets.py` lists what is inside, `scripts/check_missing.py` finds missing textures. - Write the results to `SECURITY.md` (`notes/SECURITY.md` is the real one). ## 4. Build in stages One script per stage, resumable, save the .blend after each. Rebuild order: `build_world` -> `scatter` -> `build_path`. - `build_world.py`: terrain, ground material, HDRI world with a light-path split (the HDRI lights the scene at one strength and is seen by the camera at another), sun, colour management. His numbers where he gives them. - `layout.py`: shared layout (clearing, where the path runs) that every later stage reads. - `scatter.py`: our own Geometry Nodes scatter instead of a paid add-on. One layer table with his densities and scales. 560k to 730k instances, built in about 15 s, because everything is an instance. - `build_path.py`: gravel ribbon + pad from the layout spline, his gravel material with a cleaner wobbly middle band, a transparent edge fade into the ground, leaf litter riding on the path. - `camset.py` places a look-dev camera from `CAM="x,y,height,yaw,pitch,lens"`; `test_still.py` renders a 960x540 still. 32 samples is fine for layout, but the denoiser smears gravel: judge textures at 128 samples. ## 5. Pivot when something looks dull The tutorial's cabins looked dull in our build, so we dropped them and switched to his car clip (19-32 s, 390 frames, 4 shots). Same world, new target. Don't polish something that doesn't work. ## 6. The car `build_car.py`: his Porsche model + **our own rig** instead of the add-on. The car follows the path, the wheels spin with the distance travelled, the front wheels steer into the curve, the body bounces slightly. `car.json` holds the path and speed. Part names the rig expects: `mycar_body`, `mycar_wheel.Ft.R`, `mycar_wheel.Ft.L`, `mycar_wheel.Bk.R`, `mycar_wheel.Bk.L`, `mycar_wheelbrake.Ft.R`, `mycar_wheelbrake.Ft.L`, `mycar_wheelbrake.Bk.R`, `mycar_wheelbrake.Bk.L`. ## 7. The camera shots - Capture every frame of the target clip. Have a sub-agent measure each shot: camera height, lens, fisheye, speed, cut times (`notes/clip-analysis.md` is the full measurement). - `build_shots.py` builds every shot from `shots.json`: start and end camera, lens, f-stop, focus, easing, shake, and one sun angle per shot. The 4 shots: a slow truck through leaves, a locked wide with the car crossing, a camera rigid on the car's rear corner with a fisheye, a top-down follow. ## 8. Look pass `look.py` is driven entirely by `look_params.json`: haze, sun dapples, depth of field, motion blur, colour, denser ground cover. Change numbers in the JSON, not the script. ## 9. Render - `bench.py` renders the same frames with different Cycles settings and times them. Result: **128 samples + OpenImageDenoise looks the same as 256 samples and is about 45% faster (51 s -> 28 s a frame).** - Persistent data on, so the forest is built once and stays in GPU memory between frames. - `render_final.py` / `render_final.cmd`: 2560x1440 PNG sequence, resumes after a power cut (empty placeholder frames are removed first). 390 frames took about 3 hours overnight. - `watch_render.ps1` shows frames done, speed and time left. `stay_awake_encode.ps1` keeps the PC awake (no settings changed) and makes the MP4 at the end. `screen_guard.ps1` keeps the monitor off while nobody is using the PC. - Preview first: `render_clay.py` renders a grey clay (Workbench) version of the whole clip in about 4 minutes. `preview.py` makes a fast low-quality animation preview to check timing and cuts. ## 10. Sound The render is silent. Real recorded CC0 sound effects (BigSoundBank) sounded better than generated ones: ambience, the car passing, onboard sound, whooshes. `gen_sfx.py` can generate effects with your own ElevenLabs key (`ELEVENLABS_API_KEY`) if you want that. ## Start prompt "Go watch this video: [tutorial link]. We're going to recreate his scene exactly on my GPU. Capture the transcript and the first 30 seconds at 30 fps, write PLAN.md, then tell me which assets to download. Follow the rules in the blender-forest skill." License: MIT. Made by Mr omi with Claude.skills/blender-forest/scripts/audit_blend.py918 B1eea931b42b1upstream ↗
import bpy print("##FILE", bpy.data.filepath) print("##TEXTS", [(t.name, len(t.as_string())) for t in bpy.data.texts]) drv=[] for coll in (bpy.data.objects,bpy.data.materials,bpy.data.node_groups,bpy.data.scenes,bpy.data.worlds,bpy.data.meshes,bpy.data.shape_keys,bpy.data.cameras,bpy.data.lights,bpy.data.armatures): for d in coll: ad=getattr(d,'animation_data',None) if ad: for f in ad.drivers: if f.driver.type=='SCRIPTED': drv.append((d.name,f.data_path,f.driver.expression[:80])) nt=getattr(d,'node_tree',None) if nt: for n in nt.nodes: if n.bl_idname=='ShaderNodeScript': drv.append((d.name,'OSL script node',n.name)) print("##SCRIPTED_DRIVERS", len(drv), drv[:10]) print("##ADDONS_IN_FILE n/a; libraries:", [l.filepath for l in bpy.data.libraries]) print("##OBJ", len(bpy.data.objects), "MESHES", len(bpy.data.meshes))skills/blender-forest/scripts/bench.py2.5 KB340000ddd179upstream ↗
"""Render-settings benchmark: same frames, different Cycles settings, timed per frame. BENCH='{"samples":128,"denoiser":"OPENIMAGEDENOISE"}' blender -b scene/forest.blend \ --python scripts/look.py --python scripts/bench.py -- <out_dir> <frame> [frame ...] Prints BENCH <frame> <seconds>. Always 2560x1440, full textures, persistent data. """ import bpy, sys, os, json, time, ctypes argv = sys.argv[sys.argv.index("--") + 1:] out_dir, frames = argv[0], [int(f) for f in argv[1:]] cfg = json.loads(os.environ.get("BENCH", "{}")) try: k = ctypes.windll.kernel32 k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000) except Exception: pass sc = bpy.context.scene prefs = bpy.context.preferences.addons["cycles"].preferences prefs.compute_device_type = "OPTIX" prefs.get_devices() for d in prefs.devices: d.use = d.type == "OPTIX" cy = sc.cycles cy.device = "GPU" sc.render.resolution_x, sc.render.resolution_y, sc.render.resolution_percentage = 2560, 1440, 100 sc.render.use_simplify = False sc.render.use_persistent_data = True sc.render.image_settings.file_format = "PNG" cy.samples = cfg.get("samples", 256) cy.use_adaptive_sampling = True cy.adaptive_threshold = cfg.get("threshold", 0.01) cy.use_denoising = True cy.denoiser = cfg.get("denoiser", "OPTIX") if cy.denoiser == "OPENIMAGEDENOISE": try: cy.denoising_use_gpu = True cy.denoising_prefilter = "ACCURATE" cy.denoising_quality = "HIGH" except AttributeError: pass for key in ("max_bounces", "diffuse_bounces", "glossy_bounces", "transmission_bounces", "transparent_max_bounces", "volume_bounces"): if key in cfg: setattr(cy, key, cfg[key]) if "seed" in cfg: cy.seed = cfg["seed"] if "clamp_indirect" in cfg: cy.sample_clamp_indirect = cfg["clamp_indirect"] if "caustics" in cfg: cy.caustics_reflective = cy.caustics_refractive = cfg["caustics"] if "filter_glossy" in cfg: cy.blur_glossy = cfg["filter_glossy"] print("BENCH cfg", json.dumps(cfg), "| bounces", cy.max_bounces, cy.diffuse_bounces, cy.glossy_bounces, cy.transmission_bounces, cy.transparent_max_bounces, "| clamp", cy.sample_clamp_indirect) os.makedirs(out_dir, exist_ok=True) markers = sorted((m for m in sc.timeline_markers if m.camera), key=lambda m: m.frame) for f in frames: sc.frame_set(f) cams = [m.camera for m in markers if m.frame <= f] if cams: sc.camera = cams[-1] sc.render.filepath = os.path.join(out_dir, f"r{f:04d}.png") t = time.time() bpy.ops.render.render(write_still=True) print(f"BENCH {f} {time.time() - t:.1f}")skills/blender-forest/scripts/build_car.py8.7 KB4661364d1c34upstream ↗
"""His Porsche 992 GT3 RS + our own car rig (no Rig-A-Car add-on). His file already splits the car onto 9 empties (mycar_body, mycar_wheel.Ft/Bk.L/R, mycar_wheelbrake.*). We append those empties with everything under them, drop his armature, camera, light and road, and drive the car ourselves, baked per frame from scripts/car.json: * follows the gravel road spline (layout.path_spline) at the keyed speeds * height / pitch / roll from the ground under the four tyres (raycast road + terrain) * small gravel bob on the body * wheels spin from distance travelled (s / radius), front wheels + brakes steer from curvature blender -b scene/forest.blend --python scripts/build_car.py (saves the .blend) """ import bpy, json, math, os, sys import numpy as np from mathutils import Vector, Matrix, Euler, noise HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import layout as L ROOT = os.path.dirname(HERE) CAR_BLEND = os.path.join(ROOT, "quarantine", "Porsche Gtr_Rigged", "Porsche Gtr_Rigged.blend") C = json.load(open(os.path.join(HERE, "car.json"), encoding="utf-8")) sc = bpy.context.scene RIG = ["mycar_body"] + [f"mycar_{k}.{a}.{s}" for k in ("wheel", "wheelbrake") for a in ("Ft", "Bk") for s in ("L", "R")] def clear_previous(): col = bpy.data.collections.get("Car") if col: for o in list(col.all_objects): bpy.data.objects.remove(o, do_unlink=True) bpy.data.collections.remove(col) def descendants(o): out = [o] for c in o.children: out += descendants(c) return out def import_car(): with bpy.data.libraries.load(CAR_BLEND, link=False) as (src, dst): dst.objects = list(src.objects) col = bpy.data.collections.new("Car") sc.collection.children.link(col) for o in dst.objects: if o is not None: col.objects.link(o) bpy.context.view_layer.update() keep = set() for n in RIG: keep.update(descendants(bpy.data.objects[n])) # Freeze each rig empty where his rig had it, then cut it loose from his armature. world = {n: bpy.data.objects[n].matrix_world.copy() for n in RIG} for n in RIG: o = bpy.data.objects[n] o.constraints.clear() o.animation_data_clear() o.parent = None o.matrix_world = world[n] dropped = [o for o in col.objects if o not in keep] print(f"car: keep {len(keep)} objects, drop {len(dropped)}:", sorted({o.type for o in dropped}), [o.name for o in dropped][:12]) for o in dropped: bpy.data.objects.remove(o, do_unlink=True) return col def car_frame(): P = {n: bpy.data.objects[n].matrix_world.translation.copy() for n in RIG} front = (P["mycar_wheel.Ft.L"] + P["mycar_wheel.Ft.R"]) / 2 back = (P["mycar_wheel.Bk.L"] + P["mycar_wheel.Bk.R"]) / 2 fwd = (front - back); fwd.z = 0; fwd.normalize() wb = (front - back).length track = (P["mycar_wheel.Ft.L"] - P["mycar_wheel.Ft.R"]).length # Tyre radius from the wheel's own meshes. w = bpy.data.objects["mycar_wheel.Ft.L"] zs = [(m.matrix_world @ Vector(c)).z for m in descendants(w) if m.type == "MESH" for c in m.bound_box] radius = (max(zs) - min(zs)) / 2 ground = min(zs) mid = (front + back) / 2 print(f"car: wheelbase {wb:.2f} m, track {track:.2f} m, tyre radius {radius:.3f} m") return mid, fwd, wb, track, radius, ground def build_root(mid, fwd, ground): root = bpy.data.objects.new("CarRoot", None) root.empty_display_size = 2 bpy.data.collections["Car"].objects.link(root) yaw = math.atan2(fwd.y, fwd.x) - math.pi / 2 # root +Y = car forward root.matrix_world = Matrix.Translation((mid.x, mid.y, ground)) @ Matrix.Rotation(yaw, 4, "Z") bpy.context.view_layer.update() inv = root.matrix_world.inverted() rest = {} for n in RIG: o = bpy.data.objects[n] mw = o.matrix_world.copy() o.parent = root o.matrix_parent_inverse = Matrix.Identity(4) o.matrix_basis = inv @ mw rest[n] = o.matrix_basis.copy() return root, rest def road(): pts = L.path_spline(0.05) seg = np.diff(pts, axis=0) s = np.concatenate([[0.0], np.cumsum(np.linalg.norm(seg, axis=1))]) if C["direction"] < 0: pts = pts[::-1] s = s[-1] - s[::-1] def at(d): d = float(np.clip(d, 0.0, s[-1] - 0.01)) i = int(np.searchsorted(s, d) - 1) i = max(0, min(i, len(pts) - 2)) t = (d - s[i]) / max(s[i + 1] - s[i], 1e-9) p = pts[i] + (pts[i + 1] - pts[i]) * t tg = pts[i + 1] - pts[i] tg = tg / np.linalg.norm(tg) return p, tg return at, s[-1] def ground_z(x, y): best = None for name in ("Path", "Terrain"): ob = bpy.data.objects.get(name) hit, loc, *_ = ob.ray_cast(Vector((x, y, 60.0)), Vector((0, 0, -1))) if hit: z = (ob.matrix_world @ loc).z best = z if best is None else max(best, z) return best if best is not None else 0.0 def distance_curve(): f0, f1 = C["frames"] keys = C["speed"] fr = np.arange(f0, f1 + 1) v = np.interp(fr, [k[0] for k in keys], [k[1] for k in keys]) d = C["s0"] + np.concatenate([[0.0], np.cumsum(v[:-1] / sc.render.fps)]) return fr, d def animate(root, rest, wb, track, radius): at, length = road() fr, dist = distance_curve() lat = C.get("lateral", 0.0) bob = C.get("bob_mm", 6.0) / 1000.0 prev_heading = None for f, d in zip(fr, dist): p, tg = at(d) side = np.array([tg[1], -tg[0]]) # right of travel centre = p + side * lat heading = math.atan2(tg[1], tg[0]) # Four tyre contacts on the ground. pf, tf = at(d + wb / 2) pr, tr = at(d - wb / 2) sf, sr = np.array([tf[1], -tf[0]]), np.array([tr[1], -tr[0]]) zfl = ground_z(*(pf + sf * (lat - track / 2))) zfr = ground_z(*(pf + sf * (lat + track / 2))) zrl = ground_z(*(pr + sr * (lat - track / 2))) zrr = ground_z(*(pr + sr * (lat + track / 2))) pitch = math.atan2(((zfl + zfr) - (zrl + zrr)) / 2, wb) roll = math.atan2(((zfr + zrr) - (zfl + zrl)) / 2, track) t = f / sc.render.fps z = (zfl + zfr + zrl + zrr) / 4 + bob * noise.noise(Vector((t * 7.0, 0.3, 0.0))) pitch += 0.004 * noise.noise(Vector((t * 5.0, 1.7, 0.0))) roll += 0.003 * noise.noise(Vector((t * 4.0, 3.1, 0.0))) root.location = (centre[0], centre[1], z) root.rotation_mode = "YXZ" # R = Rz(yaw) Rx(pitch) Ry(roll) yaw = heading - math.pi / 2 if prev_heading is not None: # unwrap yaw too yaw = prev_heading + ((yaw - prev_heading + math.pi) % (2 * math.pi) - math.pi) prev_heading = yaw root.rotation_euler = (pitch, -roll, yaw) root.keyframe_insert("location", frame=int(f)) root.keyframe_insert("rotation_euler", frame=int(f)) # Steering from road curvature (bicycle model), clamped. p2, t2 = at(d + 1.0) dh = math.atan2(t2[1], t2[0]) - heading dh = (dh + math.pi) % (2 * math.pi) - math.pi steer = max(-0.5, min(0.5, math.atan(wb * dh / 1.0))) spin = (d - C["s0"]) / radius for n in RIG[1:]: o = bpy.data.objects[n] r = rest[n] loc = r.to_translation() rot = r.to_3x3().to_4x4() m = Matrix.Translation(loc) if ".Ft." in n: m = m @ Matrix.Rotation(steer, 4, "Z") if "wheelbrake" not in n: m = m @ Matrix.Rotation(-spin, 4, "X") prev = o.rotation_euler.copy() o.matrix_basis = m @ rot # Keep Euler angles continuous frame to frame (no +180 -> -180 wrap), otherwise # motion blur interpolates a backwards near-full turn and smears the tyre. o.rotation_euler = o.rotation_euler.to_matrix().to_euler(o.rotation_mode, prev) o.keyframe_insert("location", frame=int(f)) o.keyframe_insert("rotation_euler", frame=int(f)) print(f"car: drove {dist[-1] - dist[0]:.1f} m over {len(fr)} frames (road {length:.0f} m)") def main(): clear_previous() import_car() mid, fwd, wb, track, radius, ground = car_frame() root, rest = build_root(mid, fwd, ground) # His model is oversized; scale to the real 992 GT3 RS wheelbase (2.46 m). k = C.get("real_wheelbase", 2.46) / wb root.scale = (k, k, k) wb, track, radius = wb * k, track * k, radius * k print(f"car: scaled x{k:.3f} -> wheelbase {wb:.2f}, track {track:.2f}, tyre r {radius:.3f}") animate(root, rest, wb, track, radius) sc.frame_set(C["frames"][0]) bpy.ops.wm.save_mainfile(relative_remap=True) print("SAVED", bpy.data.filepath) main()skills/blender-forest/scripts/build_path.py9.1 KB480d08b7ab2aupstream ↗
"""Stage D: gravel path + gravel pad in front of the cabins. His method was boolean(curve, duplicated terrain). We build the same surface directly: a ribbon that follows the smoothed path and hugs the terrain (raycast), plus a pad whose outline is snapped to a smooth rounded box. Both get a 'middle' colour attribute (1 = centre, 0 = edges) that mixes his two gravel textures, exactly like his setup. Run: blender -b scene/forest.blend --python scripts/build_path.py """ import bpy, bmesh, math, os, sys, ctypes import numpy as np from mathutils import Vector, noise sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import layout as L ROOT = os.path.join(os.path.expanduser("~"), "blender-forest") LIB_BLEND = os.path.join(ROOT, "assets", "Plant models", "assets", "AE_15th Assets.blend") try: k = ctypes.windll.kernel32 k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000) except Exception: pass STEP = 0.12 # metres between cross sections / grid cells COLS = 16 # vertices across the path EDGE_FADE = 0.8 # metres over which gravel thins out into the soil LIFT_MID, LIFT_EDGE = L.LIFT_MID, L.LIFT_EDGE # height above terrain (no z-fighting) def n1(x, seed): return noise.noise(Vector((x, seed * 13.1, seed * 7.7))) def ground_z(terrain, x, y): hit, loc, *_ = terrain.ray_cast(Vector((x, y, 80.0)), Vector((0, 0, -1))) return loc.z if hit else 0.0 def build_ribbon(bm, terrain, mid_layer, uv_layer): pts = L.path_spline(STEP) seg = np.diff(pts, axis=0) tang = np.vstack([seg, seg[-1:]]) tang /= np.linalg.norm(tang, axis=1)[:, None] side = np.stack([-tang[:, 1], tang[:, 0]], axis=1) s = np.concatenate([[0.0], np.cumsum(np.linalg.norm(seg, axis=1))]) rows = [] for i, (p, sd, d) in enumerate(zip(pts, side, s)): wl = L.PATH_HALF_WIDTH * (1.0 + 0.10 * n1(d * 0.35, 1) + 0.04 * n1(d * 2.1, 2)) wr = L.PATH_HALF_WIDTH * (1.0 + 0.10 * n1(d * 0.35, 3) + 0.04 * n1(d * 2.1, 4)) row = [] for j in range(COLS): u = -1.0 + 2.0 * j / (COLS - 1) # -1 left edge .. +1 right edge w = wl if u < 0 else wr x, y = p + sd * (u * w) crown = 1.0 - u * u z = ground_z(terrain, x, y) + LIFT_EDGE + (LIFT_MID - LIFT_EDGE) * crown v = bm.verts.new((x, y, z)) # 'middle' mask: wobbly centre band of cleaner gravel. mval = np.clip(crown * 1.4 - 0.25 + 0.35 * n1(d * 0.5, 5), 0.0, 1.0) edge = float(np.clip((1.0 - abs(u)) * w / EDGE_FADE, 0.0, 1.0)) row.append((v, mval, edge)) rows.append(row) for r0, r1 in zip(rows[:-1], rows[1:]): for j in range(COLS - 1): f = bm.faces.new((r0[j][0], r0[j + 1][0], r1[j + 1][0], r1[j][0])) for loop in f.loops: val, edge = next((m, e) for vv, m, e in (r0[j], r0[j + 1], r1[j + 1], r1[j]) if vv is loop.vert) loop[mid_layer] = (val, edge, 0.0, 1.0) loop[uv_layer].uv = (loop.vert.co.x, loop.vert.co.y) print(f"ribbon: {len(rows)} sections, length {s[-1]:.1f} m") def build_pad(bm, terrain, mid_layer, uv_layer): cx, cy = L.PAD_CENTER hx, hy = L.PAD_HALF xs = np.arange(cx - hx - 0.5, cx + hx + 0.5 + STEP, STEP) ys = np.arange(cy - hy - 0.5, cy + hy + 0.5 + STEP, STEP) gx, gy = np.meshgrid(xs, ys) xy = np.stack([gx.ravel(), gy.ravel()], axis=1) def sdf(q): wob = np.array([0.12 * n1(a * 0.6 + b * 0.4, 9) for a, b in q]) return L.dist_to_pad(q) + wob d = sdf(xy).reshape(gy.shape) inside = d < 0 keep = inside[:-1, :-1] | inside[1:, :-1] | inside[:-1, 1:] | inside[1:, 1:] used = np.zeros_like(inside) used[:-1, :-1] |= keep; used[1:, :-1] |= keep; used[:-1, 1:] |= keep; used[1:, 1:] |= keep verts = {} e = 0.02 for (iy, ix) in zip(*np.nonzero(used)): p = np.array([gx[iy, ix], gy[iy, ix]]) dv = d[iy, ix] if dv > 0: # pull outside corners onto the smooth outline g = np.array([sdf(np.array([p + [e, 0]]))[0] - sdf(np.array([p - [e, 0]]))[0], sdf(np.array([p + [0, e]]))[0] - sdf(np.array([p - [0, e]]))[0]]) / (2 * e) p = p - g / max(np.linalg.norm(g), 1e-6) * dv depth = max(-dv, 0.0) lift = LIFT_EDGE + (LIFT_MID - LIFT_EDGE) * min(depth / 0.6, 1.0) z = ground_z(terrain, p[0], p[1]) + lift mval = float(np.clip(0.55 + 0.3 * n1(p[0] * 0.4 + p[1] * 0.3, 11), 0, 1)) * min(depth / 0.8, 1.0) edge = min(depth / EDGE_FADE, 1.0) verts[(iy, ix)] = (bm.verts.new((p[0], p[1], z + 0.003)), mval, edge) # just above the ribbon for (iy, ix) in zip(*np.nonzero(keep)): quad = [verts[(iy, ix)], verts[(iy, ix + 1)], verts[(iy + 1, ix + 1)], verts[(iy + 1, ix)]] f = bm.faces.new([q[0] for q in quad]) for loop, (_, m, e) in zip(f.loops, quad): loop[mid_layer] = (m, e, 0.0, 1.0) loop[uv_layer].uv = (loop.vert.co.x, loop.vert.co.y) print(f"pad: {int(keep.sum())} quads") def gravel_material(): with bpy.data.libraries.load(LIB_BLEND, link=False) as (src, dst): dst.materials = ["AE_15th_Gravel"] mat = dst.materials[0] nt = mat.node_tree nodes = {n.name: n for n in nt.nodes} tc = nt.nodes.new("ShaderNodeTexCoord") mp = nt.nodes.new("ShaderNodeMapping") mp.inputs["Scale"].default_value = (0.5, 0.5, 0.5) # UVs are metres -> one tile per 2 m nt.links.new(tc.outputs["UV"], mp.inputs["Vector"]) for n in nt.nodes: if n.type == "TEX_IMAGE": nt.links.new(mp.outputs["Vector"], n.inputs["Vector"]) # His two-texture mix: clean gravel ('gravel soils') in the middle, gravel+soil at edges. attr = nt.nodes.new("ShaderNodeVertexColor") attr.layer_name = "middle" mix = nt.nodes.new("ShaderNodeMix") mix.data_type = "RGBA" bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED") edge_tex, mid_tex = nodes["Image Texture.002"], nodes["Image Texture"] def s(sockets, name, kind): return next(x for x in sockets if x.name == name and x.type == kind and x.enabled) # 'middle' colour: R = centre band (texture mix), G = distance-from-edge (fade). sep = nt.nodes.new("ShaderNodeSeparateColor") nt.links.new(attr.outputs["Color"], sep.inputs["Color"]) nt.links.new(sep.outputs[0], s(mix.inputs, "Factor", "VALUE")) nt.links.new(edge_tex.outputs["Color"], s(mix.inputs, "A", "RGBA")) nt.links.new(mid_tex.outputs["Color"], s(mix.inputs, "B", "RGBA")) # Lift the dark 4k scans a little (his path reads light beige in the sun). hsv = nt.nodes.new("ShaderNodeHueSaturation") hsv.inputs["Value"].default_value = 1.35 nt.links.new(s(mix.outputs, "Result", "RGBA"), hsv.inputs["Color"]) nt.links.new(hsv.outputs["Color"], bsdf.inputs["Base Color"]) nodes["Image Texture.001"].image.colorspace_settings.name = "Non-Color" # height map # Edge fade: gravel breaks up into the soil instead of a hard slab edge. noi = nt.nodes.new("ShaderNodeTexNoise") noi.inputs["Scale"].default_value = 3.0 noi.inputs["Detail"].default_value = 6.0 nt.links.new(mp.outputs["Vector"], noi.inputs["Vector"]) add = nt.nodes.new("ShaderNodeMath"); add.operation = "ADD" nt.links.new(sep.outputs[1], add.inputs[0]) sub = nt.nodes.new("ShaderNodeMath"); sub.operation = "SUBTRACT" nt.links.new(noi.outputs["Fac"], sub.inputs[0]); sub.inputs[1].default_value = 0.5 sc = nt.nodes.new("ShaderNodeMath"); sc.operation = "MULTIPLY" nt.links.new(sub.outputs[0], sc.inputs[0]); sc.inputs[1].default_value = 0.8 nt.links.new(sc.outputs[0], add.inputs[1]) ramp = nt.nodes.new("ShaderNodeMapRange") ramp.inputs["From Min"].default_value = 0.25 ramp.inputs["From Max"].default_value = 0.65 nt.links.new(add.outputs[0], ramp.inputs["Value"]) tr = nt.nodes.new("ShaderNodeBsdfTransparent") ms = nt.nodes.new("ShaderNodeMixShader") out = next(n for n in nt.nodes if n.type == "OUTPUT_MATERIAL") nt.links.new(ramp.outputs["Result"], ms.inputs["Fac"]) nt.links.new(tr.outputs[0], ms.inputs[1]) nt.links.new(bsdf.outputs[0], ms.inputs[2]) nt.links.new(ms.outputs[0], out.inputs["Surface"]) # Stronger pebble relief. for n in nt.nodes: if n.type == "BUMP": n.inputs["Strength"].default_value = 0.35 mat.displacement_method = "BOTH" return mat def main(): for name in ("Path",): o = bpy.data.objects.get(name) if o: bpy.data.objects.remove(o, do_unlink=True) terrain = bpy.data.objects["Terrain"] bm = bmesh.new() mid = bm.loops.layers.color.new("middle") uv = bm.loops.layers.uv.new("UVMap") build_ribbon(bm, terrain, mid, uv) if L.USE_CABINS: build_pad(bm, terrain, mid, uv) me = bpy.data.meshes.new("Path") bm.to_mesh(me) bm.free() for p in me.polygons: p.use_smooth = True ob = bpy.data.objects.new("Path", me) col = bpy.data.collections.get("Terrain") col.objects.link(ob) me.materials.append(gravel_material()) bpy.ops.wm.save_mainfile(relative_remap=True) print("SAVED", bpy.data.filepath, "path verts", len(me.vertices)) main()skills/blender-forest/scripts/build_shots.py8.2 KB534f6a123219upstream ↗
"""Stage F: the 5 shots of his first 16 s, driven by scripts/shots.json. Per shot: an animated camera (start -> end pose, linear or eased, optional handheld noise), lens + depth of field, its own sun angle/strength (switched on the cut, so every shot can match his lighting), and optional hand-placed hero props (fallen log, rocks...) from his library. Timeline markers switch cameras on the cuts. Frame 1 = 0.0 s, 30 fps. blender -b scene/forest.blend --python scripts/build_shots.py (saves the .blend) Pose format: {"pos": [x, y, h], "yaw": deg, "pitch": deg, "roll": deg} h = height above the ground at (x, y); yaw 0 = looking +Y; pitch 90 = level, <90 = looking down. """ import bpy, json, math, os, sys from mathutils import Vector, Matrix HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) LIB_BLEND = os.path.join(ROOT, "assets", "Plant models", "assets", "AE_15th Assets.blend") S = json.load(open(os.path.join(HERE, "shots.json"), encoding="utf-8")) sc = bpy.context.scene terrain = bpy.data.objects["Terrain"] def ground(x, y): """Highest surface under (x, y): deck, path or terrain (so cameras on the deck sit right).""" best = None for name in ("Deck", "Path", "Terrain"): # walkable surfaces only (never tree tops) ob = bpy.data.objects.get(name) if not ob: continue hit, loc, *_ = ob.ray_cast(Vector((x, y, 60.0)), Vector((0, 0, -1))) if hit: z = (ob.matrix_world @ loc).z best = z if best is None else max(best, z) return best if best is not None else 0.0 def fcurves(obj): """All F-curves of an object's action (Blender 5 layered actions, or legacy).""" ad = obj.animation_data if not ad or not ad.action: return [] act = ad.action try: out = [] for layer in act.layers: for strip in layer.strips: bag = strip.channelbag(ad.action_slot) if bag: out.extend(bag.fcurves) return out except AttributeError: return list(act.fcurves) def clear_previous(): for o in list(bpy.data.objects): if o.name.startswith("CAM_") or o.name.startswith("HERO_"): bpy.data.objects.remove(o, do_unlink=True) sc.timeline_markers.clear() sun = bpy.data.objects.get("Sun") if sun and sun.animation_data: sun.animation_data_clear() sun.data.animation_data_clear() def pose(obj, p, frame): x, y, h = p["pos"] # Parented cameras (e.g. bolted to the car) use plain local coordinates. obj.location = (x, y, h) if obj.parent else (x, y, ground(x, y) + h) obj.rotation_euler = (math.radians(p["pitch"]), math.radians(p.get("roll", 0.0)), math.radians(p["yaw"])) obj.keyframe_insert("location", frame=frame) obj.keyframe_insert("rotation_euler", frame=frame) def make_camera(shot): d = bpy.data.cameras.new("CAM_" + shot["name"]) d.lens = shot.get("lens", 24) d.sensor_width = 36 d.clip_start, d.clip_end = 0.05, 400 cam = bpy.data.objects.new("CAM_" + shot["name"], d) sc.collection.objects.link(cam) cam["shot"] = shot["name"] if shot.get("parent"): cam.parent = bpy.data.objects[shot["parent"]] cam.matrix_parent_inverse = Matrix.Identity(4) pano = shot.get("pano") if pano: # his car shot: panoramic fisheye (FOV 200, lens 23) d.type = "PANO" try: d.panorama_type = pano.get("type", "FISHEYE_EQUISOLID") d.fisheye_fov = math.radians(pano.get("fov", 200)) d.fisheye_lens = pano.get("lens", 23) except AttributeError: d.cycles.panorama_type = pano.get("type", "FISHEYE_EQUISOLID") d.cycles.fisheye_fov = math.radians(pano.get("fov", 200)) d.cycles.fisheye_lens = pano.get("lens", 23) pose(cam, shot["cam_start"], shot["start"]) pose(cam, shot["cam_end"], shot["end"]) ease = shot.get("ease", "linear") for fc in fcurves(cam): for kp in fc.keyframe_points: kp.interpolation = "LINEAR" if ease == "linear" else "BEZIER" if ease != "linear": kp.handle_left_type = kp.handle_right_type = "AUTO_CLAMPED" shake = shot.get("shake", 0.0) if shake > 0: m = fc.modifiers.new("NOISE") is_rot = fc.data_path == "rotation_euler" m.strength = shake * (0.02 if is_rot else 1.0) # metres / radians m.scale = 18.0 m.phase = 13.0 * fc.array_index + (5 if is_rot else 0) # Depth of field: focus on the frame centre at the shot's middle pose (or a fixed distance). d.dof.use_dof = shot.get("fstop", 0) > 0 if d.dof.use_dof: d.dof.aperture_fstop = shot["fstop"] f = shot.get("focus", "auto") if f == "auto": sc.frame_set((shot["start"] + shot["end"]) // 2) dg = bpy.context.evaluated_depsgraph_get() fwd = cam.matrix_world.to_quaternion() @ Vector((0, 0, -1)) hit, loc, *_ = sc.ray_cast(dg, cam.matrix_world.translation, fwd) d.dof.focus_distance = (loc - cam.matrix_world.translation).length if hit else 10.0 else: d.dof.focus_distance = float(f) m = sc.timeline_markers.new(shot["name"], frame=shot["start"]) m.camera = cam return cam def key_sun(shot): sun = bpy.data.objects["Sun"] s = shot.get("sun") if not s: return az, el = math.radians(s["azimuth"]), math.radians(s["elevation"]) to_sun = Vector((math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el))) sun.rotation_euler = (-to_sun).to_track_quat("-Z", "Y").to_euler() sun.keyframe_insert("rotation_euler", frame=shot["start"]) if "energy" in s: sun.data.energy = s["energy"] sun.data.keyframe_insert("energy", frame=shot["start"]) _PROP_COLS = {} def hero(p, i): name = p["name"] if name not in _PROP_COLS: col = bpy.data.collections.get("PROP_" + name) if not col: with bpy.data.libraries.load(LIB_BLEND, link=True) as (src, dst): dst.objects = [name] src_ob = dst.objects[0] col = bpy.data.collections.new("PROP_" + name) props = bpy.data.collections.get("PROPS") if not props: # hidden holder for linked library props props = bpy.data.collections.new("PROPS") sc.collection.children.link(props) bpy.context.view_layer.layer_collection.children["PROPS"].exclude = True props.children.link(col) col.objects.link(src_ob) mw = src_ob.matrix_basis bb = [mw @ Vector(c) for c in src_ob.bound_box] col.instance_offset = (sum(v.x for v in bb) / 8, sum(v.y for v in bb) / 8, min(v.z for v in bb)) _PROP_COLS[name] = col e = bpy.data.objects.new(f"HERO_{i}_{name}", None) e.instance_type = "COLLECTION" e.instance_collection = _PROP_COLS[name] col = bpy.data.collections.get("Hero") or bpy.data.collections.new("Hero") if col.name not in sc.collection.children: sc.collection.children.link(col) col.objects.link(e) x, y = p["pos"][:2] e.location = (x, y, ground(x, y) + p.get("z", 0.0)) e.rotation_euler = (0, 0, math.radians(p.get("rot", 0.0))) e.scale = (p.get("scale", 1.0),) * 3 def main(): clear_previous() sc.render.fps = S.get("fps", 30) for i, shot in enumerate(S["shots"]): make_camera(shot) key_sun(shot) for j, p in enumerate(shot.get("props", [])): hero(p, f"{i}{j}") sun = bpy.data.objects["Sun"] for ob in (sun, sun.data): if ob.animation_data and ob.animation_data.action: for fc in fcurves(ob): for kp in fc.keyframe_points: kp.interpolation = "CONSTANT" # sun jumps on the cut sc.frame_start = S["shots"][0]["start"] sc.frame_end = S.get("frame_end", S["shots"][-1]["end"]) sc.frame_set(sc.frame_start) sc.camera = bpy.data.objects["CAM_" + S["shots"][0]["name"]] bpy.ops.wm.save_mainfile(relative_remap=True) print("SHOTS built:", [s["name"] for s in S["shots"]], "frames", sc.frame_start, sc.frame_end) main()skills/blender-forest/scripts/build_world.py8.9 KBe1749c4cc9baupstream ↗
"""Stage B: terrain + ground material + HDRI world (light-path split) + sun + colour management. Run (headless, low priority set inside): blender -b --factory-startup --python scripts/build_world.py Writes scene/forest.blend. Re-runnable: always rebuilds from scratch. """ import bpy, bmesh, math, os, sys, ctypes from mathutils import Vector ROOT = os.path.join(os.path.expanduser("~"), "blender-forest") ASSETS = os.path.join(ROOT, "assets") TERRAIN_BLEND = os.path.join(ASSETS, "Remeshed Terrain", "Remeshed Terrain.blend") LIB_BLEND = os.path.join(ASSETS, "Plant models", "assets", "AE_15th Assets.blend") HDRI = os.path.join(ASSETS, "Ever_cabins_cloud_layers_8k", "Ever_cabins_cloud_layers_8k.hdr") OUT = os.path.join(ROOT, "scene", "forest.blend") def low_priority(): # Keep the PC responsive: BELOW_NORMAL_PRIORITY_CLASS for this Blender process. try: k = ctypes.windll.kernel32 k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000) except Exception as e: print("priority not set:", e) def clear_scene(): for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) for c in list(bpy.data.collections): bpy.data.collections.remove(c) def get_collection(name): col = bpy.data.collections.get(name) or bpy.data.collections.new(name) if col.name not in bpy.context.scene.collection.children: bpy.context.scene.collection.children.link(col) return col def append(blend, kind, name): with bpy.data.libraries.load(blend, link=False) as (src, dst): if name not in getattr(src, kind): raise RuntimeError(f"{name} not in {blend}") setattr(dst, kind, [name]) return getattr(dst, kind)[0] def build_terrain(): obj = append(TERRAIN_BLEND, "objects", "Terrain") get_collection("Terrain").objects.link(obj) bpy.context.view_layer.objects.active = obj obj.select_set(True) # Apply the 90x scale (his file keeps it unapplied). bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) # More geometry for masks/displacement (he subdivides before painting). sub = obj.modifiers.new("Subdiv", "SUBSURF") sub.subdivision_type = "SIMPLE" sub.levels = sub.render_levels = 2 bpy.ops.object.modifier_apply(modifier=sub.name) # Gentle clouds displacement: size 6, depth 5, almost flat (his settings). tex = bpy.data.textures.new("TerrainClouds", "CLOUDS") tex.noise_scale = 6.0 tex.noise_depth = 5 disp = obj.modifiers.new("Displace", "DISPLACE") disp.texture = tex disp.texture_coords = "GLOBAL" disp.strength = 1.6 disp.mid_level = 0.5 bpy.ops.object.modifier_apply(modifier=disp.name) bpy.ops.object.origin_set(type="ORIGIN_GEOMETRY", center="BOUNDS") obj.location = (0, 0, 0) bpy.ops.object.transform_apply(location=True, rotation=False, scale=False) # Level the cabin site (his cabins sit on flat ground), blending out over 6 m. import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import layout as L me = obj.data co = np.empty(len(me.vertices) * 3) me.vertices.foreach_get("co", co) co = co.reshape(-1, 3) d = L.dist_to_cabin(co[:, :2]) site_z = float(np.median(co[d < 0, 2])) w = (1.0 - L.smoothstep(0.0, 6.0, d)) * (1.0 if L.USE_CABINS else 0.0) co[:, 2] = co[:, 2] * (1.0 - w) + site_z * w me.vertices.foreach_set("co", co.ravel()) me.update() print(f"cabin site levelled at z={site_z:.2f}") # Planar top-down UVs, 0..1 across the terrain (mapping scale 50 tiles it). me = obj.data bm = bmesh.new() bm.from_mesh(me) uv = bm.loops.layers.uv.new("UVMap") xs = [v.co.x for v in bm.verts] ys = [v.co.y for v in bm.verts] minx, miny = min(xs), min(ys) size = max(max(xs) - minx, max(ys) - miny) for f in bm.faces: for l in f.loops: l[uv].uv = ((l.vert.co.x - minx) / size, (l.vert.co.y - miny) / size) bm.to_mesh(me) bm.free() for p in me.polygons: p.use_smooth = True print(f"terrain: {len(me.vertices)} verts, size {size:.1f} m") return obj def ground_material(obj): mat = append(LIB_BLEND, "materials", "AE_15th_Leafs_Soil_Ground") obj.data.materials.clear() obj.data.materials.append(mat) nt = mat.node_tree for n in nt.nodes: if n.type == "HUE_SAT": n.inputs["Saturation"].default_value = 1.2 n.inputs["Value"].default_value = 1.2 if n.type == "DISPLACEMENT": n.inputs["Scale"].default_value = 0.2 # Library material has no mapping: add UV -> Mapping (scale 50) into every image, # so the 4k soil texture tiles every ~3.6 m instead of stretching over 179 m. tc = nt.nodes.new("ShaderNodeTexCoord"); tc.location = (-1300, 0) mp = nt.nodes.new("ShaderNodeMapping"); mp.location = (-1100, 0) mp.inputs["Scale"].default_value = (50, 50, 50) nt.links.new(tc.outputs["UV"], mp.inputs["Vector"]) for n in nt.nodes: if n.type == "TEX_IMAGE": nt.links.new(mp.outputs["Vector"], n.inputs["Vector"]) mat.displacement_method = "BOTH" print("ground material nodes:", sorted({n.type for n in nt.nodes})) def build_world(): world = bpy.data.worlds.new("ForestWorld") bpy.context.scene.world = world world.use_nodes = True nt = world.node_tree nt.nodes.clear() N = nt.nodes.new L = nt.links.new img = bpy.data.images.load(HDRI, check_existing=True) def chain(y, strength, label): tc = N("ShaderNodeTexCoord"); tc.location = (-1400, y) mp = N("ShaderNodeMapping"); mp.location = (-1200, y) mp.inputs["Location"].default_value = (0, 0, 0.25) # pushes the HDRI ground below the horizon mp.inputs["Rotation"].default_value = (0, 0, math.radians(0)) env = N("ShaderNodeTexEnvironment"); env.location = (-1000, y); env.image = img gm = N("ShaderNodeGamma"); gm.location = (-700, y); gm.inputs["Gamma"].default_value = 1.0 hs = N("ShaderNodeHueSaturation"); hs.location = (-500, y) bg = N("ShaderNodeBackground"); bg.location = (-250, y); bg.label = label bg.inputs["Strength"].default_value = strength L(tc.outputs["Generated"], mp.inputs["Vector"]) L(mp.outputs["Vector"], env.inputs["Vector"]) L(env.outputs["Color"], gm.inputs["Color"]) L(gm.outputs["Color"], hs.inputs["Color"]) L(hs.outputs["Color"], bg.inputs["Color"]) return bg light = chain(300, 2.25, "Lighting") # what objects see sky = chain(-300, 1.0, "Sky") # what the camera sees directly lp = N("ShaderNodeLightPath"); lp.location = (-250, 650) mix = N("ShaderNodeMixShader"); mix.location = (0, 0) out = N("ShaderNodeOutputWorld"); out.location = (200, 0) L(lp.outputs["Is Camera Ray"], mix.inputs["Fac"]) L(light.outputs["Background"], mix.inputs[1]) L(sky.outputs["Background"], mix.inputs[2]) L(mix.outputs["Shader"], out.inputs["Surface"]) def build_sun(): data = bpy.data.lights.new("Sun", "SUN") data.energy = 10.0 data.color = (1.0, 0.95, 0.88) # slightly warm data.angle = math.radians(1.5) sun = bpy.data.objects.new("Sun", data) get_collection("Lighting").objects.link(sun) sun.location = (0, 0, 30) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import layout as L az, el = math.radians(L.SUN_AZIMUTH), math.radians(L.SUN_ELEVATION) to_sun = Vector((math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el))) sun.rotation_euler = (-to_sun).to_track_quat("-Z", "Y").to_euler() def colour_management(): sc = bpy.context.scene sc.display_settings.display_device = "sRGB" vs = sc.view_settings vs.view_transform = "Filmic" # Look enum is dynamic (enum_items only shows NONE), so set by name. vs.look = "High Contrast" vs.exposure = 1.5 vs.gamma = 0.9 print("colour:", vs.view_transform, "/", vs.look) def render_settings(): sc = bpy.context.scene sc.render.engine = "CYCLES" prefs = bpy.context.preferences.addons["cycles"].preferences prefs.compute_device_type = "OPTIX" prefs.get_devices() for d in prefs.devices: d.use = d.type == "OPTIX" sc.cycles.device = "GPU" sc.render.fps = 30 sc.render.resolution_x, sc.render.resolution_y = 1920, 1080 sc.cycles.samples = 256 sc.cycles.use_adaptive_sampling = True sc.cycles.adaptive_threshold = 0.01 sc.cycles.use_denoising = True sc.cycles.denoiser = "OPTIX" sc.render.use_motion_blur = True # Keep the CPU free for your other apps. sc.render.threads_mode = "FIXED" sc.render.threads = 8 sc.render.use_persistent_data = False def main(): low_priority() clear_scene() terrain = build_terrain() ground_material(terrain) build_world() build_sun() colour_management() render_settings() os.makedirs(os.path.dirname(OUT), exist_ok=True) bpy.ops.wm.save_as_mainfile(filepath=OUT, relative_remap=True) print("SAVED", OUT) main()skills/blender-forest/scripts/camset.py677 B796345820c60upstream ↗
"""Place a look-dev camera from env CAM="x,y,height,yaw_deg,pitch_deg,lens" (height above ground).""" import bpy, os, math from mathutils import Vector x, y, h, yaw, pitch, lens = [float(v) for v in os.environ["CAM"].split(",")] sc = bpy.context.scene dg = bpy.context.evaluated_depsgraph_get() t = bpy.data.objects["Terrain"] hit, loc, *_ = t.ray_cast(Vector((x, y, 60)), Vector((0, 0, -1))) d = bpy.data.cameras.new("TestCam"); d.lens = lens; d.clip_start = 0.05; d.clip_end = 400 c = bpy.data.objects.new("TestCam", d); sc.collection.objects.link(c) c.location = (x, y, (loc.z if hit else 0) + h) c.rotation_euler = (math.radians(pitch), 0, math.radians(yaw)) sc.camera = cskills/blender-forest/scripts/car.json573 B5cf0849a960bupstream ↗
{ "comment": "Car drive for the 19-32 s clip. Timeline frame 1 = his f570. s0 = start distance (m) along the road spline from its north end; direction +1 drives south (PATH order), -1 drives north. speed keys: [frame, metres per second], linear between keys.", "s0": 40.0, "direction": 1, "lateral": 0.15, "speed": [ [ 1, 6.0 ], [ 76, 6.0 ], [ 77, 4.3 ], [ 186, 4.3 ], [ 187, 7.5 ], [ 291, 7.5 ], [ 292, 6.5 ], [ 400, 6.5 ] ], "frames": [ 1, 400 ], "bob_mm": 6.0, "paint": null }skills/blender-forest/scripts/check_missing.py216 B301475629899upstream ↗
import bpy, os miss=[i.filepath for i in bpy.data.images if i.source=='FILE' and not os.path.exists(bpy.path.abspath(i.filepath))] print("##IMAGES", len(bpy.data.images), "MISSING", len(miss), sorted(set(miss))[:8])skills/blender-forest/scripts/gen_sfx.py1.7 KB351e29529c72upstream ↗
"""Generate the clip's sound effects with your ElevenLabs account (sound-generation API). Standard library only. Writes audio/<name>.mp3.""" import json, os, sys, urllib.request KEY = os.environ.get("ELEVENLABS_API_KEY") OUT = os.path.join(os.path.expanduser("~"), "blender-forest", "audio") SFX = { "forest_amb": ("Calm summer forest ambience, birds chirping in the distance, gentle wind moving through leaves, no people", 14.0), "car_far": ("Porsche 911 GT3 RS flat-six engine approaching from a distance on a gravel forest road, tyres crunching gravel, driving past slowly", 5.0), "car_onboard": ("Close-up recording at the rear wheel of a sports car driving fast on a gravel road, loud flat-six engine, gravel crunching and small stones hitting the underbody, wind rush", 4.0), "car_away": ("Sports car with a flat-six engine driving away on a gravel forest road, engine fading into the distance, birds in the background", 4.0), "whoosh": ("Short subtle cinematic whoosh transition, airy, soft", 1.0), } if not KEY: sys.exit("ELEVENLABS_API_KEY not set") os.makedirs(OUT, exist_ok=True) for name, (text, dur) in SFX.items(): path = os.path.join(OUT, name + ".mp3") if os.path.exists(path): print("skip", name); continue req = urllib.request.Request( "https://api.elevenlabs.io/v1/sound-generation", data=json.dumps({"text": text, "duration_seconds": dur, "prompt_influence": 0.45}).encode(), headers={"xi-api-key": KEY, "Content-Type": "application/json", "Accept": "audio/mpeg"}) with urllib.request.urlopen(req, timeout=120) as r: open(path, "wb").write(r.read()) print("ok", name, os.path.getsize(path), "bytes")skills/blender-forest/scripts/inspect_assets.py724 B905d3c2dd14dupstream ↗
import bpy, collections print("##OBJ", len(bpy.data.objects), "COLL", len(bpy.data.collections), "MAT", len(bpy.data.materials), "IMG", len(bpy.data.images)) packed=sum(1 for i in bpy.data.images if i.packed_file); print("##IMG packed", packed, "of", len(bpy.data.images)) missing=[i.filepath for i in bpy.data.images if not i.packed_file and i.source=='FILE'][:5]; print("##IMG external sample", missing) c=collections.Counter() for o in bpy.data.objects: n=o.name.rsplit('.',1)[0] import re; n=re.sub(r'[_ ]?\d+$','',n); c[(n,o.type)]+=1 for k,v in sorted(c.items()): print("##NAME",k,v) print("##ASSETS", len([o for o in bpy.data.objects if o.asset_data]), len([o for o in bpy.data.collections if o.asset_data]))skills/blender-forest/scripts/layout.py4.1 KB0b98fa519b18upstream ↗
"""Shared scene layout: where the cabins sit and where the gravel path runs. Every stage (scatter masks, path boolean, cabins, cameras) reads these numbers, so moving the cabin or bending the path is a one-place change. Units: metres, terrain is a ~179 m disc centred on the origin. """ import numpy as np # Cabin clearing (two A-frames + deck), rounded box. CABIN_CENTER = (0.0, 6.0) CABIN_HALF = (13.0, 9.0) # half size x, y CABIN_CORNER = 4.0 # corner rounding # Gravel path: leaves the cabin deck southwards and winds to the terrain edge. # No cabins any more: the road now runs right through the forest (north end extended). PATH = [(6.0, 70.0), (2.0, 52.0), (-4.0, 36.0), (-6.0, 20.0), (-2.0, 8.0), (0.0, -3.0), (1.5, -14.0), (-2.5, -27.0), (-9.0, -39.0), (-8.0, -53.0), (-1.0, -66.0), (5.0, -80.0), (8.0, -90.0)] PATH_HALF_WIDTH = 1.75 # 3.5 m gravel road (car is ~1.9 m wide) USE_CABINS = False # user dropped the cabins 2026-10-19 # Sun: his frames are backlit (sun behind trees/cabins). Azimuth from +X, degrees. SUN_AZIMUTH = 110.0 SUN_ELEVATION = 40.0 # Path surface sits this far above the terrain (centre / edge) to avoid z-fighting. LIFT_MID, LIFT_EDGE = 0.03, 0.005 # Cabins (from his frames). Local frame: gable/front faces -Y, floor top at z=0. CABIN_A = dict(center=(-5.0, 7.2), rot=-15.0, W=6.6, D=10.0, eave=2.9, ridge=8.0) CABIN_B = dict(center=(5.2, 8.0), rot=31.0, W=5.2, D=7.5, eave=2.6, ridge=6.6) # Deck = union of rectangles (cx, cy, sx, sy, rot_deg), top a little above the highest ground. DECK_RECTS = [(-5.1, 6.95, 8.4, 12.5, -15.0), (1.2, 4.0, 5.2, 4.8, 0.0), (5.2, 8.0, 6.8, 9.1, 31.0)] DECK_THICK = 0.32 # Gravel pad between the path end and the cabin deck. PAD_CENTER = (0.0, 0.5) PAD_HALF = (7.0, 2.5) PAD_CORNER = 2.0 def path_spline(step=0.1): """Catmull-Rom through PATH, resampled every `step` m. Returns (N,2).""" p = np.asarray(PATH, dtype=np.float64) p = np.vstack([2 * p[0] - p[1], p, 2 * p[-1] - p[-2]]) out = [] for i in range(1, len(p) - 2): p0, p1, p2, p3 = p[i - 1], p[i], p[i + 1], p[i + 2] n = max(2, int(np.linalg.norm(p2 - p1) / step)) for t in np.linspace(0.0, 1.0, n, endpoint=False): t2, t3 = t * t, t * t * t out.append(0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3)) out.append(p[-2]) return np.asarray(out) def dist_to_pad(xy): c = np.asarray(PAD_CENTER) h = np.asarray(PAD_HALF) - PAD_CORNER q = np.abs(xy - c) - h return (np.linalg.norm(np.maximum(q, 0.0), axis=1) + np.minimum(np.max(q, axis=1), 0.0) - PAD_CORNER) def dist_to_deck(xy): """Signed distance to the union of deck rectangles (negative on the deck).""" best = np.full(len(xy), np.inf) for cx, cy, sx, sy, rot in DECK_RECTS: r = np.radians(rot) d = xy - np.array([cx, cy]) lx = d[:, 0] * np.cos(r) + d[:, 1] * np.sin(r) ly = -d[:, 0] * np.sin(r) + d[:, 1] * np.cos(r) q = np.abs(np.stack([lx, ly], axis=1)) - np.array([sx / 2, sy / 2]) sd = np.linalg.norm(np.maximum(q, 0.0), axis=1) + np.minimum(np.max(q, axis=1), 0.0) best = np.minimum(best, sd) return best def dist_to_path(xy): """xy: (N,2) array -> distance to the smoothed path centre line.""" p = path_spline(0.5) best = np.full(len(xy), np.inf) for a, b in zip(p[:-1], p[1:]): ab = b - a t = np.clip(((xy - a) @ ab) / (ab @ ab), 0.0, 1.0) proj = a + t[:, None] * ab best = np.minimum(best, np.linalg.norm(xy - proj, axis=1)) return best def dist_to_cabin(xy): """Signed distance to the rounded cabin clearing (negative inside).""" c = np.asarray(CABIN_CENTER) h = np.asarray(CABIN_HALF) - CABIN_CORNER q = np.abs(xy - c) - h outside = np.linalg.norm(np.maximum(q, 0.0), axis=1) inside = np.minimum(np.max(q, axis=1), 0.0) return outside + inside - CABIN_CORNER def smoothstep(e0, e1, x): t = np.clip((x - e0) / (e1 - e0), 0.0, 1.0) return t * t * (3.0 - 2.0 * t)skills/blender-forest/scripts/look.py7.5 KBacb3f764528fupstream ↗
"""Stage G: realism / look pass, driven entirely by scripts/look_params.json. Applies on top of the built scene (run AFTER camset.py when testing, so DOF can focus): sun (direction, strength, softness), world light/sky split, colour management, haze from the mist pass, glare, colour grade, vignette, slight lens dispersion, camera DOF. LOOK_SAVE=1 blender -b scene/forest.blend --python scripts/look.py # bake into the .blend CAM=... blender -b scene/forest.blend --python scripts/camset.py --python scripts/look.py --python scripts/test_still.py -- out.png 64 960 # test still, no save """ import bpy, json, math, os from mathutils import Vector HERE = os.path.dirname(os.path.abspath(__file__)) P = json.load(open(os.path.join(HERE, "look_params.json"), encoding="utf-8")) sc = bpy.context.scene def sun(): s = P["sun"] ob = bpy.data.objects.get("Sun") ob.data.angle = math.radians(s["angle_deg"]) ob.data.color = s["color"] if ob.animation_data and ob.animation_data.action: return # per-shot sun from build_shots.py wins az, el = math.radians(s["azimuth"]), math.radians(s["elevation"]) to_sun = Vector((math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el))) ob.rotation_euler = (-to_sun).to_track_quat("-Z", "Y").to_euler() ob.data.energy = s["energy"] ob.data.angle = math.radians(s["angle_deg"]) # smaller = crisper leaf dapples ob.data.color = s["color"] def world(): w = P["world"] for n in sc.world.node_tree.nodes: if n.type == "BACKGROUND": n.inputs["Strength"].default_value = w["light_strength"] if n.label == "Lighting" else w["sky_strength"] if n.type == "MAPPING": n.inputs["Rotation"].default_value[2] = math.radians(w["hdri_rot_z_deg"]) m = sc.world.mist_settings m.start, m.depth, m.falloff = P["haze"]["start"], P["haze"]["depth"], "QUADRATIC" def colour(): c = P["colour"] vs = sc.view_settings vs.view_transform = "Filmic" vs.look = c["look"] vs.exposure, vs.gamma = c["exposure"], c["gamma"] sc.render.motion_blur_shutter = P.get("shutter", 0.5) # his car shot is only lightly blurred def pick(sockets, name, kind=None): return next(s for s in sockets if s.name == name and (kind is None or s.type == kind)) def set_menu(sock, *options): for o in options: try: sock.default_value = o return except Exception: pass def compositor(): bpy.context.view_layer.use_pass_mist = True ng = bpy.data.node_groups.get("LookComp") or bpy.data.node_groups.new("LookComp", "CompositorNodeTree") ng.nodes.clear() if not ng.interface.items_tree: ng.interface.new_socket("Image", in_out="OUTPUT", socket_type="NodeSocketColor") N, Lk = ng.nodes.new, ng.links.new rl = N("CompositorNodeRLayers") out = N("NodeGroupOutput") # Haze: mix toward haze colour by mist * strength. h = P["haze"] # Sky pixels have mist == 1: keep them out of the haze so the blue sky stays blue. notsky = N("ShaderNodeMath"); notsky.operation = "LESS_THAN" Lk(rl.outputs["Mist"], notsky.inputs[0]); notsky.inputs[1].default_value = 0.995 m1 = N("ShaderNodeMath"); m1.operation = "MULTIPLY" Lk(rl.outputs["Mist"], m1.inputs[0]); Lk(notsky.outputs[0], m1.inputs[1]) fac = N("ShaderNodeMath"); fac.operation = "MULTIPLY" Lk(m1.outputs[0], fac.inputs[0]); fac.inputs[1].default_value = h["strength"] haze = N("ShaderNodeMix"); haze.data_type = "RGBA" Lk(fac.outputs[0], pick(haze.inputs, "Factor", "VALUE")) Lk(rl.outputs["Image"], pick(haze.inputs, "A", "RGBA")) pick(haze.inputs, "B", "RGBA").default_value = (*h["color"], 1.0) img = pick(haze.outputs, "Result", "RGBA") # Glare (soft bloom on sun-lit leaves / windows). g = P["glare"] gl = N("CompositorNodeGlare") set_menu(gl.inputs["Type"], "Bloom", "BLOOM", "Fog Glow", "FOG_GLOW") gl.inputs["Threshold"].default_value = g["threshold"] gl.inputs["Strength"].default_value = g["strength"] if "Size" in gl.inputs: gl.inputs["Size"].default_value = g["size"] Lk(img, gl.inputs["Image"]); img = gl.outputs["Image"] # Grade: lift / gamma / gain, then saturation. gr = P["grade"] cb = N("CompositorNodeColorBalance") for nm in ("Lift", "Gamma", "Gain"): pick(cb.inputs, nm, "RGBA").default_value = (*gr[nm.lower()], 1.0) Lk(img, cb.inputs["Image"]); img = cb.outputs["Image"] hs = N("CompositorNodeHueSat") hs.inputs["Saturation"].default_value = gr["saturation"] Lk(img, hs.inputs["Image"]); img = hs.outputs["Image"] # Lens: tiny chromatic dispersion like a real lens. ld = N("CompositorNodeLensdist") ld.inputs["Dispersion"].default_value = P["lens"]["dispersion"] Lk(img, ld.inputs["Image"]); img = ld.outputs["Image"] # Vignette: soft ellipse mask darkening the corners. em = N("CompositorNodeEllipseMask") em.inputs["Size"].default_value = (0.95, 0.95) bl = N("CompositorNodeBlur") bl.inputs["Size"].default_value = (300, 300) Lk(em.outputs["Mask"], bl.inputs["Image"]) vf = N("ShaderNodeMath"); vf.operation = "MULTIPLY_ADD" Lk(bl.outputs["Image"], vf.inputs[0]) vf.inputs[1].default_value = P["vignette"] vf.inputs[2].default_value = 1.0 - P["vignette"] vg = N("ShaderNodeMix"); vg.data_type = "RGBA"; vg.blend_type = "MULTIPLY" pick(vg.inputs, "Factor", "VALUE").default_value = 1.0 Lk(img, pick(vg.inputs, "A", "RGBA")) Lk(vf.outputs[0], pick(vg.inputs, "B", "RGBA")) Lk(pick(vg.outputs, "Result", "RGBA"), out.inputs[0]) sc.compositing_node_group = ng sc.render.use_compositing = True def dof(): d = P["dof"] cam = sc.camera if not cam or "shot" in cam: return # shot cameras carry their own DOF cam.data.dof.use_dof = d["enabled"] cam.data.dof.aperture_fstop = d["fstop"] # Focus on whatever is in the centre of frame. dg = bpy.context.evaluated_depsgraph_get() fwd = cam.matrix_world.to_quaternion() @ Vector((0, 0, -1)) hit, loc, *_ = sc.ray_cast(dg, cam.matrix_world.translation, fwd) cam.data.dof.focus_distance = (loc - cam.matrix_world.translation).length if hit else 10.0 def materials(): """look_params "materials": {"<material name>": {"value": 0.9, "saturation": 1.0}} -> HSV before Base Color.""" for name, o in P.get("materials", {}).items(): m = bpy.data.materials.get(name) if not m or not m.node_tree: print("material not found:", name) continue nt = m.node_tree bsdf = next((n for n in nt.nodes if n.type == "BSDF_PRINCIPLED"), None) if not bsdf: continue hsv = nt.nodes.get("LookHSV") if not hsv: src = bsdf.inputs["Base Color"].links[0].from_socket if bsdf.inputs["Base Color"].links else None hsv = nt.nodes.new("ShaderNodeHueSaturation") hsv.name = "LookHSV" if src: nt.links.new(src, hsv.inputs["Color"]) else: hsv.inputs["Color"].default_value = bsdf.inputs["Base Color"].default_value nt.links.new(hsv.outputs["Color"], bsdf.inputs["Base Color"]) hsv.inputs["Value"].default_value = o.get("value", 1.0) hsv.inputs["Saturation"].default_value = o.get("saturation", 1.0) sun(); world(); colour(); compositor(); dof(); materials() print("LOOK applied") if os.environ.get("LOOK_SAVE") == "1": bpy.ops.wm.save_mainfile(relative_remap=True) print("LOOK SAVED")skills/blender-forest/scripts/look_params.json1.2 KB1f7103171172upstream ↗
{ "sun": { "azimuth": 268.0, "elevation": 42.0, "energy": 24.0, "angle_deg": 0.6, "color": [ 1.0, 0.94, 0.84 ] }, "world": { "light_strength": 1.6, "sky_strength": 1.0, "hdri_rot_z_deg": 0.0 }, "colour": { "exposure": 1.45, "gamma": 0.95, "look": "High Contrast" }, "haze": { "start": 12.0, "depth": 110.0, "strength": 0.25, "color": [ 0.78, 0.84, 0.9 ] }, "glare": { "threshold": 1.0, "strength": 0.15, "size": 0.5 }, "grade": { "lift": [ 1.0, 1.0, 1.0 ], "gamma": [ 1.0, 1.0, 1.0 ], "gain": [ 1.03, 1.02, 0.97 ], "saturation": 0.98 }, "vignette": 0.22, "lens": { "dispersion": 0.004 }, "dof": { "enabled": true, "fstop": 4.0 }, "scatter": { "Ferns": { "density_mul": 2.0, "scale_mul": 1.0 }, "GroundPlants": { "density_mul": 1.8, "scale_mul": 1.0 }, "Leaves": { "density_mul": 0.7, "scale_mul": 1.0 } }, "materials": { "AE_15th_Gravel": { "value": 0.8, "saturation": 1.0 }, "Wooden_Planks_01": { "value": 0.85, "saturation": 1.0 }, "Black_Painted Wood": { "value": 2.2, "saturation": 1.0 } }, "shutter": 0.35 }skills/blender-forest/scripts/preview.py1.5 KB042823922d69upstream ↗
"""Fast animation preview: whole clip, low quality, to check motion/timing/cuts. blender -b scene/forest.blend --python scripts/look.py --python scripts/preview.py -- <out_dir> Writes <out_dir>/p####.png (skips frames already rendered, so it resumes after a power cut). """ import bpy, sys, os, ctypes out_dir = sys.argv[sys.argv.index("--") + 1] try: k = ctypes.windll.kernel32 k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000) # below normal except Exception: pass sc = bpy.context.scene prefs = bpy.context.preferences.addons["cycles"].preferences prefs.compute_device_type = "OPTIX" prefs.get_devices() for d in prefs.devices: d.use = d.type == "OPTIX" cy = sc.cycles cy.device = "GPU" cy.samples = 4 cy.use_adaptive_sampling = False cy.use_denoising = True cy.denoiser = "OPTIX" cy.max_bounces, cy.diffuse_bounces, cy.glossy_bounces = 3, 1, 1 cy.transmission_bounces, cy.transparent_max_bounces = 2, 4 sc.render.resolution_x, sc.render.resolution_y, sc.render.resolution_percentage = 960, 540, 100 sc.render.use_simplify = True cy.texture_limit_render = "256" sc.render.use_motion_blur = False sc.render.use_persistent_data = True sc.render.image_settings.file_format = "PNG" sc.render.use_overwrite = False # resume: keep frames already on disk sc.render.use_placeholder = True os.makedirs(out_dir, exist_ok=True) sc.render.filepath = os.path.join(out_dir, "p####") bpy.ops.render.render(animation=True) # timeline markers switch the shot cameras print("PREVIEW DONE", sc.frame_start, sc.frame_end)skills/blender-forest/scripts/render_clay.py1.7 KB7a2954016478upstream ↗
"""Grey clay (Workbench, Solid shading) version of the 19-32 s car clip, for the tutorial's fake Blender viewport. 1280x720 PNG sequence in render/clay, resumable. Pass --test to time a single frame (frame 200) instead. blender -b --disable-autoexec scene/forest.blend --python scripts/render_clay.py [-- --test] """ import bpy, os, sys, time ROOT = os.path.join(os.path.expanduser("~"), "blender-forest") OUT = os.path.join(ROOT, "render", "clay") test = "--test" in sys.argv sc = bpy.context.scene sc.render.engine = "BLENDER_WORKBENCH" sh = sc.display.shading sh.light = "STUDIO" sh.color_type = "SINGLE" sh.single_color = (0.8, 0.8, 0.8) sh.show_cavity = False sh.show_shadows = False sh.shadow_intensity = 0.45 sh.show_object_outline = False sc.display.render_aa = "OFF" sc.render.film_transparent = False w = sc.world if w: # Workbench uses the world viewport colour as the background w.color = (0.24, 0.24, 0.24) sc.render.resolution_x, sc.render.resolution_y, sc.render.resolution_percentage = 960, 540, 100 sc.render.use_motion_blur = False sc.render.image_settings.file_format = "PNG" sc.render.image_settings.color_depth = "8" sc.render.use_overwrite = False sc.render.use_placeholder = True os.makedirs(OUT, exist_ok=True) print("CLAY settings ok", flush=True) if test: sc.frame_set(200) sc.render.filepath = os.path.join(OUT, "test_f0200.png") t = time.time() bpy.ops.render.render(write_still=True) print(f"CLAY TEST frame 200: {time.time() - t:.1f} s (frames {sc.frame_start}-{sc.frame_end})") else: sc.render.filepath = os.path.join(OUT, "c####") print("CLAY start", sc.frame_start, sc.frame_end, "->", OUT) bpy.ops.render.render(animation=True) print("CLAY DONE")skills/blender-forest/scripts/render_final.cmd363 Bf3b964cc73b7upstream ↗
@echo off rem Final render: resumes after a power cut. Removes empty placeholder frames first. cd /d %USERPROFILE%\blender-forest for %%F in (render\final_2k\*.png) do if %%~zF==0 del "%%F" "C:\Program Files\Blender Foundation\Blender 5.2\blender.exe" -b scene\forest.blend --python scripts\look.py --python scripts\render_final.py > render\final_render.log 2>&1skills/blender-forest/scripts/render_final.py1.6 KB3e84026a8aa1upstream ↗
"""Final render of the 19-32 s clip: 2560x1440, 128 samples + OpenImageDenoise (benchmarked: no visible difference vs 256 samples, ~45% faster), motion blur on, PNG sequence. Resumes after a power cut (frames already on disk are skipped). blender -b scene/forest.blend --python scripts/look.py --python scripts/render_final.py """ import bpy, os, ctypes ROOT = os.path.join(os.path.expanduser("~"), "blender-forest") OUT = os.path.join(ROOT, "render", "final_2k") try: k = ctypes.windll.kernel32 k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000) # below normal: PC stays usable except Exception: pass sc = bpy.context.scene prefs = bpy.context.preferences.addons["cycles"].preferences prefs.compute_device_type = "OPTIX" prefs.get_devices() for d in prefs.devices: d.use = d.type == "OPTIX" cy = sc.cycles cy.device = "GPU" cy.samples = 128 cy.use_adaptive_sampling = True cy.adaptive_threshold = 0.01 cy.use_denoising = True cy.denoiser = "OPENIMAGEDENOISE" try: cy.denoising_use_gpu = True cy.denoising_prefilter = "ACCURATE" cy.denoising_quality = "HIGH" except AttributeError: pass sc.render.resolution_x, sc.render.resolution_y, sc.render.resolution_percentage = 2560, 1440, 100 sc.render.use_simplify = False sc.render.use_motion_blur = True sc.render.use_persistent_data = True sc.render.image_settings.file_format = "PNG" sc.render.image_settings.color_depth = "16" sc.render.use_overwrite = False sc.render.use_placeholder = True os.makedirs(OUT, exist_ok=True) sc.render.filepath = os.path.join(OUT, "f####") print("FINAL start", sc.frame_start, sc.frame_end, "->", OUT) bpy.ops.render.render(animation=True) print("FINAL DONE")skills/blender-forest/scripts/render_frames.py1.9 KB72969816153bupstream ↗
"""Render chosen frames of the shot timeline (look-dev / checks). Does not save the .blend. blender -b scene/forest.blend --python scripts/look.py --python scripts/render_frames.py -- <out_dir> <samples> <width> <frame> [frame ...] Writes <out_dir>/r####.png (#### = timeline frame). """ import bpy, sys, os, ctypes argv = sys.argv[sys.argv.index("--") + 1:] out_dir, samples, width = argv[0], int(argv[1]), int(argv[2]) frames = [int(f) for f in argv[3:]] try: k = ctypes.windll.kernel32 k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000) # below normal: PC stays usable except Exception: pass sc = bpy.context.scene prefs = bpy.context.preferences.addons["cycles"].preferences prefs.compute_device_type = "OPTIX" prefs.get_devices() for d in prefs.devices: d.use = d.type == "OPTIX" sc.cycles.device = "GPU" sc.cycles.samples = samples sc.render.resolution_x, sc.render.resolution_y = width, round(width * 9 / 16) sc.render.resolution_percentage = 100 if os.environ.get("FULL") == "1": # final quality: full-res textures sc.render.use_simplify = False else: sc.render.use_simplify = True sc.cycles.texture_limit_render = "2048" sc.cycles.use_denoising = True sc.cycles.denoiser = "OPTIX" sc.render.image_settings.file_format = "PNG" # Keep the static forest (730k instances, BVH, textures) on the GPU between frames instead of # rebuilding it on the CPU for every frame - that rebuild was most of the render time. sc.render.use_persistent_data = True os.makedirs(out_dir, exist_ok=True) markers = sorted((m for m in sc.timeline_markers if m.camera), key=lambda m: m.frame) for f in frames: sc.frame_set(f) cams = [m.camera for m in markers if m.frame <= f] if cams: sc.camera = cams[-1] sc.render.filepath = os.path.join(out_dir, f"r{f:04d}.png") bpy.ops.render.render(write_still=True) print("FRAME", f, sc.camera.name, sc.render.filepath)skills/blender-forest/scripts/scatter.py16 KB0574ec81e79aupstream ↗
"""Stage C: our own scatter system (replaces his paid Geo-Scatter). How it works: 1. Link (not copy) his plant objects from the library .blend into hidden LIB_* collections. 2. Compute one density-factor attribute per layer on the terrain vertices in Python (path/cabin clearings, focus area, Musgrave-like patterns, his "reduce density"). 3. Each layer = an empty mesh object with a Geometry Nodes modifier we build here: Object Info(Terrain) -> Distribute Points (density x attribute) -> Instance on Points with his rotation/scale rules. Everything stays instanced (12 GB VRAM friendly). Run: blender -b scene/forest.blend --python scripts/scatter.py Re-runnable: removes the previous scatter first, then saves scene/forest.blend. """ import bpy, math, os, sys, ctypes import numpy as np from mathutils import noise, Vector sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import layout as L ROOT = os.path.join(os.path.expanduser("~"), "blender-forest") LIB_BLEND = os.path.join(ROOT, "assets", "Plant models", "assets", "AE_15th Assets.blend") P = "AE_15TH_" try: k = ctypes.windll.kernel32 k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000) # below normal except Exception: pass def names(prefix, nums): return [f"{P}{prefix}_{n:02d}" for n in nums] # Layer table: his numbers from the tutorial where he gives them. # density = instances per m2 before masks (Geo-Scatter "instances") # vi = vertical influence (1 = straight up, 0 = follow ground normal) # scale = (min, max) uniform scale; zmul squashes height LAYERS = [ dict(name="BirchTrees", objs=names("BIRCH", range(1, 12)), density=0.06, poisson=1.5, mask="trees", pattern=(1 / 25.0, 1.0, 1.0), pattern_infl=0.3, vi=0.85, tilt=5, scale=(1.6, 1.6), seed=11), dict(name="YoungTrees", objs=names("TREE_YOUNG", (1, 2)), density=0.05, poisson=1.5, mask="trees", vi=0.85, tilt=5, scale=(0.7, 1.3), seed=12), dict(name="BigTrees", objs=names("TREE", (1, 2, 3)), density=0.012, poisson=5.0, mask="trees", vi=0.9, tilt=3, scale=(0.9, 1.2), seed=13), dict(name="LargeShrubs", objs=names("FOREST_BUSH", range(1, 8)), density=0.5, mask="shrubs", pattern=(1 / 12.0, 0.7, 1.6), vi=0.4, tilt=8, scale=(1.07, 1.6), seed=14), dict(name="SmallShrubs", objs=names("FOREST_BUSH", range(3, 8)), density=0.4, mask="cabin_ring", vi=0.5, tilt=0, scale=(0.35, 0.7), seed=15), dict(name="Ferns", objs=names("FERN", range(1, 8)), density=5.0, mask="ground", pattern=(1 / 4.0, 1.23, 1.87), pattern_infl=0.6, vi=0.5, tilt=10, scale=(0.6, 1.5), seed=16), dict(name="Branches", objs=names("BRANCH", [1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]), density=1.85, mask="ground", vi=0.0, tilt=0, scale=(0.5, 1.5), seed=17), dict(name="Debris", objs=names("DEBRIS", range(3, 16)), density=3.0, mask="ground", vi=0.0, tilt=0, scale=(0.34, 1.0), zoff=0.01, seed=18), dict(name="Leaves", objs=names("DRY_LEAVES", (1, 2, 3)), density=200.0 * 0.18, # 70% reduce + thinned (litter read as tiles) mask="leaves", pattern=(1 / 10.0, 0.9, 1.0), pattern_infl=0.9, vi=0.0, tilt=0, scale=(0.4, 0.8), seed=19), dict(name="GroundPlants", objs=names("GROUND_PLANT", range(1, 7)), density=24.0 * 0.5, # 50% reduce mask="ground", vi=0.5, tilt=0, scale=(0.6, 1.4), zmul=0.9, seed=20), # Seen in his frames (rocks, fallen logs, stumps) but placed by hand in the video. dict(name="Rocks", objs=names("ROCK", range(1, 9)), density=0.04, mask="ground", vi=0.3, tilt=20, scale=(0.6, 1.6), zoff=-0.05, seed=21), dict(name="FallenTrees", objs=names("FALLEN_TREE", range(1, 8)), density=0.008, poisson=4.0, mask="ground", vi=0.0, tilt=0, scale=(0.9, 1.4), zoff=-0.03, seed=22), dict(name="Stumps", objs=names("TREE_STUMP", (1, 2)), density=0.002, mask="trees", vi=0.6, tilt=0, scale=(0.8, 1.2), zoff=-0.03, seed=23), ] # ---------------------------------------------------------------- cleanup def remove_previous(): for o in list(bpy.data.objects): if o.name.startswith("Scatter_"): me = o.data bpy.data.objects.remove(o, do_unlink=True) if me and me.users == 0: bpy.data.meshes.remove(me) for ng in list(bpy.data.node_groups): if ng.name.startswith("ScatterGN_"): bpy.data.node_groups.remove(ng) for c in list(bpy.data.collections): if c.name.startswith("LIB_") or c.name in ("LIBRARY", "Scatter"): for o in list(c.objects): c.objects.unlink(o) bpy.data.collections.remove(c) # ---------------------------------------------------------------- library def link_library(): wanted = sorted({n for layer in LAYERS for n in layer["objs"]}) with bpy.data.libraries.load(LIB_BLEND, link=True) as (src, dst): missing = [n for n in wanted if n not in src.objects] if missing: raise RuntimeError(f"missing in library: {missing}") dst.objects = wanted linked = {o.name: o for o in dst.objects} lib_root = bpy.data.collections.new("LIBRARY") bpy.context.scene.collection.children.link(lib_root) for layer in LAYERS: col = bpy.data.collections.new("LIB_" + layer["name"]) lib_root.children.link(col) for n in layer["objs"]: col.objects.link(linked[n]) layer["collection"] = col # Excluded from the view layer: never rendered directly, only through instancing. bpy.context.view_layer.layer_collection.children["LIBRARY"].exclude = True # ---------------------------------------------------------------- masks def fbm(xy, freq, seed): off = Vector((seed * 31.7, seed * 17.3, seed * 5.1)) return np.array([noise.fractal(Vector((x * freq, y * freq, 0.0)) + off, 1.0, 2.0, 4, noise_basis="PERLIN_ORIGINAL") for x, y in xy]) def pattern(xy, freq, bright, contrast, seed, infl=1.0): n = fbm(xy, freq, seed) v = np.clip(((0.5 + 0.5 * n) - 0.5) * contrast + 0.5, 0.0, 1.0) * bright v = np.clip(v, 0.0, 1.0) return 1.0 - infl + infl * v def compute_masks(terrain): me = terrain.data co = np.empty(len(me.vertices) * 3) me.vertices.foreach_get("co", co) xy = co.reshape(-1, 3)[:, :2] d_path = L.dist_to_path(xy) d_cab = L.dist_to_cabin(xy) r = np.linalg.norm(xy, axis=1) hw = L.PATH_HALF_WIDTH edge_ok = 1.0 - L.smoothstep(84.0, 88.0, r) near = np.minimum(d_path, d_cab) focus = 1.0 - L.smoothstep(40.0, 55.0, near) if L.USE_CABINS: d_deck = L.dist_to_deck(xy) d_pad = L.dist_to_pad(xy) else: # no cabins: nothing to clear there, forest fills in d_deck = d_pad = np.full(len(xy), 1e9) d_cab = np.full(len(xy), 1e9) near = d_path clear = np.maximum.reduce([1.0 - L.smoothstep(hw - 0.2, hw + 1.0, d_path), 1.0 - L.smoothstep(-0.2, 0.6, d_deck), 1.0 - L.smoothstep(-0.3, 0.8, d_pad)]) focus = np.maximum(1.0 - L.smoothstep(40.0, 55.0, near), 1.0 - L.smoothstep(20.0, 30.0, d_cab)) masks = { "trees": L.smoothstep(3.0, 5.0, d_path) * L.smoothstep(4.0, 7.0, d_cab) * edge_ok, "shrubs": L.smoothstep(2.0, 3.5, d_path) * L.smoothstep(2.0, 5.0, d_cab) * edge_ok, "ground": (1.0 - clear) * focus * edge_ok, "leaves": L.smoothstep(hw - 0.9, hw + 0.3, d_path) * (1.0 - clear * 0.85) * focus * edge_ok, "cabin_ring": L.smoothstep(-0.5, 0.5, d_cab) * (1.0 - L.smoothstep(4.0, 8.0, d_cab)) * L.smoothstep(2.0, 3.0, d_path), } # Path surface height above terrain, so litter at the gravel edges sits on top of it. t = np.clip(d_path / L.PATH_HALF_WIDTH, 0.0, 1.0) lift = np.where(d_path < L.PATH_HALF_WIDTH * 1.15, L.LIFT_EDGE + (L.LIFT_MID - L.LIFT_EDGE) * (1.0 - t * t), 0.0) lift = np.maximum(lift, np.where(L.dist_to_pad(xy) < 0, L.LIFT_MID, 0.0)) la = me.attributes.get("lift") or me.attributes.new("lift", "FLOAT", "POINT") la.data.foreach_set("value", lift.astype(np.float32)) # Store a "center" weight like his vertex group (1 = cabin + path) for later stages. vg = terrain.vertex_groups.get("center") or terrain.vertex_groups.new(name="center") for i, w in enumerate(clear): if w > 0.001: vg.add([i], float(w), "REPLACE") for layer in LAYERS: f = masks[layer["mask"]].copy() if "pattern" in layer: freq, bright, contrast = layer["pattern"] f *= pattern(xy, freq, bright, contrast, layer["seed"], layer.get("pattern_infl", 1.0)) attr_name = "d_" + layer["name"] a = me.attributes.get(attr_name) or me.attributes.new(attr_name, "FLOAT", "POINT") a.data.foreach_set("value", f.astype(np.float32)) layer["attr"] = attr_name print(f"mask {layer['name']:13s} mean {f.mean():.3f}") # ---------------------------------------------------------------- geometry nodes def sock(sockets, name, kind=None): for s in sockets: if s.name == name and s.enabled and (kind is None or s.type == kind): return s raise KeyError(f"{name} ({kind})") def build_group(layer, terrain): ng = bpy.data.node_groups.new("ScatterGN_" + layer["name"], "GeometryNodeTree") ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") N, Ln = ng.nodes.new, ng.links.new gin = N("NodeGroupInput"); gout = N("NodeGroupOutput") info = N("GeometryNodeObjectInfo") info.transform_space = "RELATIVE" info.inputs["Object"].default_value = terrain attr = N("GeometryNodeInputNamedAttribute") attr.data_type = "FLOAT" attr.inputs["Name"].default_value = layer["attr"] dist = N("GeometryNodeDistributePointsOnFaces") if "poisson" in layer: dist.distribute_method = "POISSON" sock(dist.inputs, "Distance Min").default_value = layer["poisson"] sock(dist.inputs, "Density Max").default_value = layer["density"] Ln(sock(attr.outputs, "Attribute"), sock(dist.inputs, "Density Factor")) else: dist.distribute_method = "RANDOM" mul = N("ShaderNodeMath"); mul.operation = "MULTIPLY" mul.inputs[1].default_value = layer["density"] Ln(sock(attr.outputs, "Attribute"), mul.inputs[0]) Ln(mul.outputs[0], sock(dist.inputs, "Density")) sock(dist.inputs, "Seed").default_value = layer["seed"] Ln(info.outputs["Geometry"], dist.inputs["Mesh"]) # Rotation: lean between ground normal and world up, then random tilt + yaw. mix = N("ShaderNodeMix"); mix.data_type = "VECTOR" sock(mix.inputs, "Factor", "VALUE").default_value = layer["vi"] Ln(sock(dist.outputs, "Normal"), sock(mix.inputs, "A", "VECTOR")) sock(mix.inputs, "B", "VECTOR").default_value = (0, 0, 1) nrm = N("ShaderNodeVectorMath"); nrm.operation = "NORMALIZE" Ln(sock(mix.outputs, "Result", "VECTOR"), nrm.inputs[0]) align = N("FunctionNodeAlignRotationToVector"); align.axis = "Z" Ln(nrm.outputs["Vector"], sock(align.inputs, "Vector")) t = math.radians(layer["tilt"]) rnd_rot = N("FunctionNodeRandomValue"); rnd_rot.data_type = "FLOAT_VECTOR" sock(rnd_rot.inputs, "Min", "VECTOR").default_value = (-t, -t, 0.0) sock(rnd_rot.inputs, "Max", "VECTOR").default_value = (t, t, 2 * math.pi) sock(rnd_rot.inputs, "Seed").default_value = layer["seed"] + 100 e2r = N("FunctionNodeEulerToRotation") Ln(sock(rnd_rot.outputs, "Value", "VECTOR"), e2r.inputs[0]) rot = N("FunctionNodeRotateRotation") try: rot.rotation_space = "LOCAL" except AttributeError: pass Ln(sock(align.outputs, "Rotation"), sock(rot.inputs, "Rotation")) Ln(e2r.outputs[0], sock(rot.inputs, "Rotate By")) # Scale: random uniform in (min, max), optional z squash. rnd_s = N("FunctionNodeRandomValue"); rnd_s.data_type = "FLOAT" sock(rnd_s.inputs, "Min", "VALUE").default_value = layer["scale"][0] sock(rnd_s.inputs, "Max", "VALUE").default_value = layer["scale"][1] sock(rnd_s.inputs, "Seed").default_value = layer["seed"] + 200 zm = N("ShaderNodeMath"); zm.operation = "MULTIPLY" zm.inputs[1].default_value = layer.get("zmul", 1.0) s_out = sock(rnd_s.outputs, "Value", "VALUE") Ln(s_out, zm.inputs[0]) cxyz = N("ShaderNodeCombineXYZ") Ln(s_out, cxyz.inputs[0]); Ln(s_out, cxyz.inputs[1]); Ln(zm.outputs[0], cxyz.inputs[2]) # Random pick of which plant model. rnd_i = N("FunctionNodeRandomValue"); rnd_i.data_type = "INT" sock(rnd_i.inputs, "Min", "INT").default_value = 0 sock(rnd_i.inputs, "Max", "INT").default_value = 9999 sock(rnd_i.inputs, "Seed").default_value = layer["seed"] + 300 col = N("GeometryNodeCollectionInfo") col.transform_space = "ORIGINAL" col.inputs["Collection"].default_value = layer["collection"] sock(col.inputs, "Separate Children").default_value = True sock(col.inputs, "Reset Children").default_value = True # Offset = (0, 0, lift + zoff): his z offset, plus riding on top of the gravel. lift = N("GeometryNodeInputNamedAttribute"); lift.data_type = "FLOAT" lift.inputs["Name"].default_value = "lift" add = N("ShaderNodeMath"); add.operation = "ADD" add.inputs[1].default_value = layer.get("zoff", 0.0) Ln(sock(lift.outputs, "Attribute"), add.inputs[0]) off = N("ShaderNodeCombineXYZ") Ln(add.outputs[0], off.inputs[2]) sp = N("GeometryNodeSetPosition") Ln(dist.outputs["Points"], sp.inputs["Geometry"]) Ln(off.outputs[0], sp.inputs["Offset"]) pts = sp.outputs["Geometry"] iop = N("GeometryNodeInstanceOnPoints") Ln(pts, iop.inputs["Points"]) Ln(col.outputs[0], iop.inputs["Instance"]) sock(iop.inputs, "Pick Instance").default_value = True Ln(sock(rnd_i.outputs, "Value", "INT"), sock(iop.inputs, "Instance Index")) Ln(rot.outputs[0], sock(iop.inputs, "Rotation")) Ln(cxyz.outputs[0], sock(iop.inputs, "Scale")) Ln(iop.outputs["Instances"], gout.inputs[0]) return ng def build_layers(terrain): scol = bpy.data.collections.new("Scatter") bpy.context.scene.collection.children.link(scol) for layer in LAYERS: me = bpy.data.meshes.new("Scatter_" + layer["name"]) ob = bpy.data.objects.new("Scatter_" + layer["name"], me) scol.objects.link(ob) mod = ob.modifiers.new("Scatter", "NODES") mod.node_group = build_group(layer, terrain) mod.show_viewport = False # keep the UI light when the file is opened by hand def report(): dg = bpy.context.evaluated_depsgraph_get() counts = {} for inst in dg.object_instances: if inst.is_instance and inst.parent and inst.parent.name.startswith("Scatter_"): k = inst.parent.name[8:] counts[k] = counts.get(k, 0) + 1 for k, v in counts.items(): print(f"instances {k:13s} {v:8d}") print("instances total", sum(counts.values())) def apply_overrides(): """look_params.json -> "scatter": {"Ferns": {"density_mul": 1.5, "scale_mul": 1.1}, ...}""" import json p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "look_params.json") if not os.path.exists(p): return ov = json.load(open(p, encoding="utf-8")).get("scatter", {}) for layer in LAYERS: o = ov.get(layer["name"]) if o: layer["density"] *= o.get("density_mul", 1.0) sm = o.get("scale_mul", 1.0) layer["scale"] = (layer["scale"][0] * sm, layer["scale"][1] * sm) print("override", layer["name"], o) def main(): apply_overrides() remove_previous() terrain = bpy.data.objects["Terrain"] link_library() compute_masks(terrain) build_layers(terrain) if "--count" in sys.argv: for o in bpy.data.objects: if o.name.startswith("Scatter_"): o.modifiers["Scatter"].show_viewport = True report() for o in bpy.data.objects: if o.name.startswith("Scatter_"): o.modifiers["Scatter"].show_viewport = False bpy.ops.wm.save_mainfile(relative_remap=True) print("SAVED", bpy.data.filepath) main()skills/blender-forest/scripts/screen_guard.ps11.3 KB75cebb56f259upstream ↗
# Keeps the monitor OFF whenever nobody has touched mouse/keyboard for 15 s. # Notifications can't keep it on: it is switched off again within ~3 s. # Runs up to 10 hours. To stop early: create the file render\stop_guard Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; public class G { [StructLayout(LayoutKind.Sequential)] public struct LII { public uint cbSize; public uint dwTime; } [DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LII p); [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l); public static uint IdleMs() { LII l = new LII(); l.cbSize = (uint)Marshal.SizeOf(l); GetLastInputInfo(ref l); return (uint)Environment.TickCount - l.dwTime; } public static void Off() { PostMessage((IntPtr)0xffff, 0x0112, (IntPtr)0xF170, (IntPtr)2); } } "@ $root = Join-Path $env:USERPROFILE "blender-forest\render" $log = Join-Path $root "screen_guard.log" $stop = Join-Path $root "stop_guard" Remove-Item $stop -ErrorAction SilentlyContinue "guard v2 started $(Get-Date)" | Out-File $log $end = (Get-Date).AddHours(10) while ((Get-Date) -lt $end -and -not (Test-Path $stop)) { if ([G]::IdleMs() -gt 15000) { [G]::Off() } # PostMessage never blocks Start-Sleep -Seconds 3 } "guard stopped $(Get-Date)" | Out-File $log -Appendskills/blender-forest/scripts/shots.json2.7 KBc1afdf8703dbupstream ↗
{ "fps": 30, "frame_end": 390, "comment": "His 19.0-32.0 s (f570-f959). Timeline frame 1 = his f570. Car drives south on the road (see car.json).", "shots": [ { "name": "S1", "start": 1, "end": 50, "lens": 40, "fstop": 5.6, "focus": 10.0, "ease": "linear", "shake": 0.0, "cam_start": { "pos": [ 9.0, 16.0, 0.6 ], "yaw": 135, "pitch": 95, "roll": 0.0 }, "cam_end": { "pos": [ 8.85, 16.2, 0.6 ], "yaw": 135, "pitch": 95, "roll": 0.0 }, "sun": { "azimuth": 200.0, "elevation": 45.0, "energy": 30.0 }, "props": [ { "name": "AE_15TH_FOREST_BUSH_01", "pos": [ 8.0, 15.6 ], "rot": 20.0, "scale": 1.4, "z": 0.0 }, { "name": "AE_15TH_TREE_YOUNG_02", "pos": [ 8.5, 14.6 ], "rot": 30.0, "scale": 0.9, "z": -4.0 }, { "name": "AE_15TH_TREE_YOUNG_01", "pos": [ 5.5, 13.5 ], "rot": 0.0, "scale": 0.8 } ] }, { "name": "S2", "start": 51, "end": 186, "lens": 26, "fstop": 8.0, "focus": 15.0, "ease": "linear", "shake": 0.0, "cam_start": { "pos": [ 8.0, 14.0, 1.2 ], "yaw": 130, "pitch": 90.0, "roll": 0.0 }, "cam_end": { "pos": [ 8.0, 14.0, 1.2 ], "yaw": 130, "pitch": 90.0, "roll": 0.0 }, "sun": { "azimuth": 215.0, "elevation": 50.0, "energy": 28.0 }, "props": [ { "name": "AE_15TH_BIRCH_04", "pos": [ 6.5, 11.4 ], "rot": 10.0, "scale": 1.3 } ] }, { "name": "S3", "start": 187, "end": 291, "parent": "CarRoot", "pano": { "type": "FISHEYE_EQUISOLID", "fov": 200, "lens": 28 }, "fstop": 0, "ease": "linear", "shake": 0.0, "cam_start": { "pos": [ -2.1, -2.8, 0.65 ], "yaw": -18, "pitch": 91, "roll": 0.0 }, "cam_end": { "pos": [ -2.1, -2.8, 0.65 ], "yaw": -18, "pitch": 91, "roll": 0.0 }, "sun": { "azimuth": 270.0, "elevation": 45.0, "energy": 24.0 } }, { "name": "S4", "start": 292, "end": 390, "lens": 20, "fstop": 4.0, "focus": 8.5, "ease": "linear", "shake": 0.0, "cam_start": { "pos": [ 1.0, -30.5, 9.0 ], "yaw": 180.0, "pitch": 0.0, "roll": 0.0 }, "cam_end": { "pos": [ -5.6, -45.8, 8.0 ], "yaw": 180.0, "pitch": 0.0, "roll": 0.0 }, "sun": { "azimuth": 250.0, "elevation": 60.0, "energy": 24.0 }, "props": [] } ] }skills/blender-forest/scripts/stay_awake_encode.ps11.2 KB665550dbb69dupstream ↗
# Keeps the PC awake (no settings changed) while the final render runs, then makes the MP4. Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; public class Awake { [DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint f); } "@ $ES_CONTINUOUS = [uint32]2147483648; $ES_SYSTEM_REQUIRED = [uint32]1 $root = Join-Path $env:USERPROFILE "blender-forest" $log = Join-Path $root "render\stay_awake.log" "started $(Get-Date)" | Out-File $log [Awake]::SetThreadExecutionState($ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED) | Out-Null while (Get-CimInstance Win32_Process -Filter "Name='blender.exe'" | Where-Object { $_.CommandLine -like '*render_final.py*' }) { [Awake]::SetThreadExecutionState($ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED) | Out-Null Start-Sleep 60 } $n = (Get-ChildItem "$root\render\final_2k" -Filter "f*.png" | Where-Object Length -gt 0).Count "render ended $(Get-Date), frames $n" | Out-File $log -Append if ($n -ge 390) { & ffmpeg -loglevel error -y -framerate 30 -i "$root\render\final_2k\f%04d.png" -c:v libx264 -preset slow -crf 14 -pix_fmt yuv420p "$root\render\forest_car_19-32s_2K.mp4" "mp4 done $(Get-Date)" | Out-File $log -Append } [Awake]::SetThreadExecutionState($ES_CONTINUOUS) | Out-Nullskills/blender-forest/scripts/test_still.py1.9 KBe86790a307f9upstream ↗
"""Cheap look-dev still from scene/forest.blend (does not modify the .blend). blender -b scene/forest.blend --python scripts/test_still.py -- <out.png> [samples] [width] """ import bpy, sys, os, math, ctypes from mathutils import Vector argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] out = argv[0] if argv else os.path.join(os.path.expanduser("~"), "blender-forest", "scene", "tests", "still.png") samples = int(argv[1]) if len(argv) > 1 else 32 width = int(argv[2]) if len(argv) > 2 else 960 try: k = ctypes.windll.kernel32 k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000) # below normal except Exception: pass sc = bpy.context.scene prefs = bpy.context.preferences.addons["cycles"].preferences prefs.compute_device_type = "OPTIX" prefs.get_devices() for d in prefs.devices: d.use = d.type == "OPTIX" sc.cycles.device = "GPU" cam = sc.camera if cam is None: # Default look-dev camera: eye level (~0.6 m) like shot 2, looking across the ground. data = bpy.data.cameras.new("LookDev") data.lens = 28 cam = bpy.data.objects.new("LookDev", data) sc.collection.objects.link(cam) terrain = bpy.data.objects["Terrain"] dg = bpy.context.evaluated_depsgraph_get() x, y = 6.0, -4.0 hit, loc, *_ = sc.ray_cast(dg, Vector((x, y, 50)), Vector((0, 0, -1))) z = loc.z if hit else 0.0 cam.location = (x, y, z + 0.6) cam.rotation_euler = (math.radians(86), 0, math.radians(20)) sc.camera = cam sc.render.resolution_x = width sc.render.resolution_y = round(width * 9 / 16) sc.render.resolution_percentage = 100 sc.cycles.samples = samples sc.render.use_simplify = True sc.cycles.texture_limit_render = "2048" sc.render.use_motion_blur = False sc.render.image_settings.file_format = "PNG" sc.render.filepath = out os.makedirs(os.path.dirname(out), exist_ok=True) bpy.ops.render.render(write_still=True) print("STILL", out)skills/blender-forest/scripts/watch_render.ps12.4 KBb799b4f0ab41upstream ↗
# Live progress for the final render: frames done, speed, time left. $dir = Join-Path $env:USERPROFILE "blender-forest\render\final_2k" $total = 390 $host.UI.RawUI.WindowTitle = "Forest render - progress" $start = Get-Date $startDone = $null while ($true) { $files = @(Get-ChildItem $dir -Filter "f*.png" -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 0 } | Sort-Object LastWriteTime) $done = $files.Count if ($null -eq $startDone) { $startDone = $done } Clear-Host Write-Host "" Write-Host " FOREST CLIP - FINAL 2K RENDER (2560x1440, 128 samples + OIDN)" -ForegroundColor Cyan Write-Host " ---------------------------------------------------------------" $pct = [math]::Round(100 * $done / $total, 1) $bar = ("#" * [int]($pct / 2.5)).PadRight(40, ".") Write-Host (" [{0}] {1}%" -f $bar, $pct) Write-Host (" Frames done : {0} / {1}" -f $done, $total) if ($files.Count -ge 3) { $recent = $files | Select-Object -Last ([math]::Min(20, $files.Count)) $span = ($recent[-1].LastWriteTime - $recent[0].LastWriteTime).TotalSeconds $per = $span / [math]::Max(1, $recent.Count - 1) $left = ($total - $done) * $per $eta = (Get-Date).AddSeconds($left) Write-Host (" Speed : {0:N1} s per frame" -f $per) Write-Host (" Time left : {0}h {1:D2}m" -f [int][math]::Floor($left / 3600), [int][math]::Floor(($left % 3600) / 60)) Write-Host (" Finishes at : {0:HH:mm}" -f $eta) Write-Host (" Last frame : {0} ({1:HH:mm:ss})" -f $files[-1].Name, $files[-1].LastWriteTime) } else { Write-Host " Speed : measuring... (first frame loads the forest, ~1 min)" } $gpu = (nvidia-smi --query-gpu=utilization.gpu,memory.used,temperature.gpu --format=csv,noheader 2>$null) if ($gpu) { Write-Host (" GPU : {0} (util, VRAM, temp)" -f $gpu) } $running = Get-Process blender -ErrorAction SilentlyContinue Write-Host (" Blender : {0}" -f $(if ($running) { "rendering" } else { "not running" })) if ($done -ge $total) { Write-Host "`n DONE - all frames rendered." -ForegroundColor Green } elseif (-not $running) { Write-Host "`n Stopped (power cut?). Restart the render; it resumes from the last frame." -ForegroundColor Yellow } Write-Host "`n (this window only watches; closing it does not stop the render)" Start-Sleep 10 }skills/blender-forest/notes/camera_moves.md6.2 KB78d8a5214d31upstream ↗
# Camera motion analysis (visual estimates from contact sheets + full frames; numbers are approximate) Conventions: truck = sideways move, dolly = along view axis, pitch = degrees below horizon (+ = looking down, - = looking up). Frame thirds: L/C/R, top/mid/low. ## Shot 1 (f0000-f0105) - top-down ferns/logs - Move: near-vertical look-down; camera glides FORWARD over the ground (content slides DOWN frame and slightly right). Ground-plane dolly/truck ~0.9-1.2 m total. Last frames start to tilt up: gravel path appears at top-right (horizon creeping in). - Speed: smooth, roughly constant, perhaps a gentle ease-in; no shake. - Height: ~2.0-2.5 m start and end (a ~0.6 m fern spans ~25% of frame width). Pitch ~88 deg start -> ~75 deg end (slow tilt-up of ~10-15 deg). - Lens ~35 mm FF (little distortion, frame covers ~2.3 m wide). DOF: shallow-ish; mid/low ferns and logs sharp, far top edge softer. - Composition start: fallen log lying upper-left to centre (log centre ~ x 0.3, y 0.3), big fern lower-left (0.35, 0.65), fern cluster lower-right, a second fern at upper-centre. End: log pushed to bottom edge (y ~0.9), mossy rock + fern in centre-right, a second log upper-left, gravel strip top-right corner. - Light: strong dappled sun, high, from upper-left; dark shade pockets, bright fern patches; high contrast. ## Shot 2 (f0106-f0216) - low ground-level truck - Move: pure TRUCK LEFT (foreground rocks/ferns slide RIGHT ~45% of frame width; the far dark trunk slides right only ~14%) = parallax, no pan. ~1.0-1.5 m travel. Tiny pedestal-up / tilt drift (path line sinks ~10 px in 640 tile, i.e. horizon goes slightly lower). - Speed: near constant, perhaps tiny ease at ends. Perfectly smooth, no handheld. - Height ~0.35-0.5 m (ferns are at eye level), pitch ~0-3 deg (level; horizon/path line at ~48% from top, vanishing back to 25 m of forest). - Lens ~28-35 mm. Foreground ferns/rocks in near-focus but slightly soft; midground gravel path and tall trunk sharp-ish; distant canopy softer. Mild DOF. - Composition start: dark tree trunk at x~0.17 (left third), gravel path band across middle (y~0.45-0.5), fern clump centre-low, white fallen log right-mid, sky slivers top. End: trunk x~0.3, rock cluster centre-right low (0.55-0.8, y 0.8), path band wider/left, fern lower-right. - Light: back/side-lit (sun in front-left, high): trunks dark, foliage glowing, ferns translucent rim light, soft patches on ground (weak-medium). ## Shot 3 (f0217-f0337) - path walk toward the bend - Move: DOLLY FORWARD ~4-5 m along the path plus slight yaw/truck to the left (trees slide right, path bend drifts right and widens toward the lower right). Slight tilt up of ~5 deg and small height drop. - Speed: constant walking pace (~0.04 m/frame-ish -> ~1.2 m/s), eased out slightly at the end. Smooth, no shake. - Height ~1.7 m -> ~1.5 m; pitch ~14 deg down at start -> ~6-8 deg at end. - Lens ~24-28 mm (wide, strong parallax in ferns). DOF: moderate; path and mid-ground trees sharp, far canopy slightly soft. - Composition start: path S-curve occupying right half, lower-right diagonal (path enters bottom-centre-right, bends away at top x~0.55), thin white birch left-third (x 0.25), forest bank left-low. End: horizon at ~y 0.33, path enters bottom-centre/left, bends right to x~0.75-0.8, dark trunk at x~0.85, birches left, fern bank low-left. - Light: sun high (50-60 deg), from camera LEFT-behind/side; heavy dappled shadows on the gravel (strong, ~60-70% contrast), warm bright forest bank. ## Shot 4 (f0338-f0442) - cabin reveal at distance - Move: slow DOLLY FORWARD ~1 m (cabin A grows ~7%) + TRUCK LEFT ~1.5 m (foreground trees/branches slide RIGHT by ~15-20% of width). Static pitch. - Speed: slow, constant to mild ease-out. Smooth. - Height ~1.2 m; pitch ~0-2 deg (level). Distance to cabin A ~14-17 m. - Lens ~28-35 mm. DOF: cabins and deck crisp; foreground ferns/branches slightly soft; background trees soft. - Composition start: cabin A gable left (x 0.1-0.43, apex at y 0.05, chimney pipe on top), cabin B right (x 0.5-0.73, apex y 0.2), gravel path in lower-left/centre (y 0.8-1), foreground birch trunk right (x 0.75), deck strip at y ~0.64. End: same but path centred-low (x 0.5), cabin A x 0.08-0.4, cabin B 0.5-0.72, foreground birch at x 0.9, deck y ~0.65-0.7 slightly bigger. - Light: sun HIGH and from camera-left, slightly behind; tree shadows fall across cabin A gable (strong, patchy), B's left roof slope lit, right face in shade; blue sky with clouds top-centre. ## Shot 5 (f0443-f0528) - close cabin push/arc - Move: TRUCK RIGHT ~1.8 m (deck corner slides left from x 0.77 to 0.47 of frame; the cabin A side wall is progressively revealed = orbit-like) with a small dolly in of ~0.5 m. Little or no pan. - Speed: eased - fastest at start (~100 px/36 f), about 20-25% slower late. NOT stopped. - STILL MOVING at f0480 and at f0528 (frame diffs non-zero throughout; deck corner at x 0.62 at f480 -> 0.47 at f528). Does not settle - shot ends mid-motion with slight ease. - Height ~0.6-0.8 m (deck 0.3 m thick fills lower 12% of frame); pitch ~ -3 to -5 deg (looking slightly UP so roof apex nearly clipped at the top). - Lens ~24 mm (wide, converging verticals, cabin A gable tops cropped). DOF: cabin glass, wall, deck edge sharp; far trees soft, tiny fern/foreground branch blurred at bottom-left. - Composition start: cabin A fills left third (x 0-0.47, glazing centre-left y 0.3-0.65), cabin B right-centre (x 0.58-0.93, apex top y 0), woodstack cabinet right (x 0.88), deck front edge sweeping from lower-left to centre (y 0.8-0.95), gravel right-low, sky top-centre. End: cabin A corner window + side wall sliding to x 0-0.62 diagonal, cabin B now x 0.55-0.93 more frontal and smaller, deck corner centre (x 0.47, y 0.85), more gravel in lower right, sky strip top-centre/right. - Light: sun high, from camera LEFT-behind; cabin B's gable half-lit, soft dappled shadows across cabin A wall; gravel and deck brightly lit; moderate sun-patch contrast; blue sky with clouds. ## Quick notes for Blender - No handheld shake anywhere; use smooth linear/ease-out curves. Cuts hide changes in lens: shots 1-3 ~28-35 mm, shot 4 ~28-35 mm, shot 5 ~24 mm. - Sun: one high sun lamp (elevation ~55 deg) from camera-left/behind in shots 3-5; shot 2 is sunlit from front-left (rim light); strong leaf-cookie dappling.skills/blender-forest/notes/clip-analysis.md17 KBac9abdd76b99upstream ↗
# Clip 19.0 s – 32.0 s (f0570 – f0960 at 30 fps): shot-by-shot breakdown Verified cuts: f0620, f0756, f0861 (all HARD cuts, no dissolve, flash, whip, speed ramp, text or grain change). No fourth cut up to f0959. Frame refs are 1920x1080; x/y given as fractions of frame (0,0 = top-left). "Est." = my visual estimate, not measured in 3D. ## Per-second timeline | Sec | Frames | Shot | What happens | |---|---|---|---| | 19 | 570-599 | S1 | Low creeping move through blurred leaves. Near sapling trunk drifts x 0.76 to 0.69. Heavy dark out-of-focus leaves across top 45%. Sky patches top-left. | | 20 | 600-629 | S1 to S2 | S1 continues to f619 (sapling at x 0.65, big dark blurred leaves sweep in lower right). **CUT f620** to static wide: curved birch trunk left (x 0.22-0.35), white-backed leaves bottom right, no car yet. | | 21 | 630-659 | S2 | Locked-off. Car appears at the right edge around f646-648 (x 1.0, y 0.62), moving left. By f659 car x 0.88-1.0. | | 22 | 660-689 | S2 | Car crosses right to centre-right: centre x 0.92 (f660) to 0.70 (f690), ~14 px/frame, partly hidden by foreground branches/ferns. | | 23 | 690-719 | S2 | Car centre x 0.70 to 0.57, slows on screen (~10 to 8 px/frame), gets smaller (length 440 px to ~330 px). | | 24 | 720-749 | S2 | Car x 0.55 to 0.42, ~8 px/frame, length ~300 px, receding along the road. | | 25 | 750-779 | S2 to S3 | Car at x 0.40, length ~270 px, y 0.60. **CUT f756 (25.2 s)** to ultra-low close-up of the left rear quarter, rigidly attached to the car. Background streaks (motion blur). | | 26 | 780-809 | S3 | Car frozen in frame; world streams past; sky patch top-left grows. | | 27 | 810-839 | S3 | Same; background blur eases slightly. Gravel road at lower left with dappled sun. | | 28 | 840-869 | S3 to S4 | S3 ends f860. **CUT f861 (28.7 s)** to top-down view through blurred foliage, ground orange leaf litter. | | 29 | 870-899 | S4 | Bush drifts down-frame and out; gravel road (right edge) comes in from top; car roof first peeks at bottom right around f887-890. | | 30 | 900-929 | S4 | Car rises into lower right (roof then windscreen then bonnet). Pale-green leaf cluster enters top-left. | | 31 | 930-959 | S4 | Car fully in lower right, nose up-frame, enlarging; ground flows down at ~6 px/frame. Clip ends mid-move at f959. | --- ## SHOT 1 — f0570 to f0619 (clip starts mid-shot; 50 frames = 1.67 s, 19.0-20.63 s) **Camera** - Position: very low, est. 0.5-0.8 m above ground, just above the fern tops (ferns visible bottom 20%). Looking horizontally north-ish into a clearing; far-clearing floor "horizon" sits at y ≈ 0.69-0.71 and does not move, so NO tilt/pan, no roll. - Move: slow truck/slider to the RIGHT (scene drifts left). Near sapling trunk (about 1-1.5 m away) x: 1450 px (f570), 1370 (f590), 1290 (f610), 1250 (f619) = constant ~4 px/frame (~120 px/s). Second, farther dark trunk: 1490, 1465, 1445, 1430 = ~1 px/frame. That parallax ratio ≈ 4:1 implies camera speed only ~5-10 cm/s. Essentially linear, maybe a hair of ease-out at the end. No shake. Total travel in the shot ≈ 0.15 m. Possibly a tiny push-in; no measurable zoom. - Foreground leaves move much faster than the sapling (10-20 px/frame) because they are almost at the lens (and probably wind-animated). **Lens** - ~35-50 mm, no visible distortion. SHALLOW depth of field (wide aperture ~f/2-2.8): focus on the sapling and twigs 1.5-3 m out. Big leaves in the top 45% (and sweeping lower right at f610-619) are huge soft dark-green blobs 150-350 px across. Far background (trees 10 m+) is moderately soft. Ferns/litter at the bottom are soft-ish. Motion blur on leaves: moderate, 180° shutter look. **Car:** not visible. **Foreground occluders:** continuous. Dark, backlit out-of-focus leaves fill top-left to top-right; individual leaf blobs ~200 px. Between f604-619 one big dark blurred leaf slides in from the right edge (x 0.77-0.95, y 0.6-0.75). They are dark (backlit, translucent edges) and low-contrast. **Environment:** deciduous understory (young beech/hornbeam-like saplings with thin black twigs, small heart-shaped leaves); forest floor = brown leaf litter + clumps of fern; a fallen log fragment bottom-left (x 0.25-0.33, y 0.82). A pale clearing/old track floor behind the sapling (y 0.69). Sky: small pale-blue/white patches through the canopy top-left and top centre-right. No road visible. **Light:** sun high, behind the subject (backlit/rim-lit leaves, translucent yellow-green edges), clearing in the middle glows. Dapple is weak because the whole frame is under canopy. Exposure: slightly hazy, lifted blacks, medium contrast. Grade: soft yellow-green highlights, cool desaturated green shadows, low-moderate saturation, slight bloom on bright gaps. **Composition:** start: sapling at right third line (x 0.76), second trunk at 0.78, ground 15-20% bottom, leaf mass top. Middle: sapling x 0.70. End: sapling x 0.65, camera about to cut to the wide. Bright clearing slot centre. --- ## SHOT 2 — f0620 to f0755 (136 frames = 4.53 s, 20.67-25.17 s) **Camera** - Locked-off tripod shot. Birch trunk (left) stays at x 420-690 px at bottom and 410-470 at top in every frame; foreground leaf branches (bottom right) identical in f620 and f755. Only tiny drift/wind sway of the top-right pale leaf (≤10 px). No shake, no zoom. - Height: est. 1.0-1.3 m (looking slightly down at the forest floor; the track appears as a pale strip at y 0.63-0.68, car's roof is at y ≈ 0.56-0.58). Slight downhill/upslope: the ground rises toward the back; the car's road sits on a gentle bench. - Position: a few metres from the track, camera facing roughly across it; the track runs from the right edge (x 1.0) to centre-left, curving away. Car travels right to LEFT. **Lens:** ~24-28 mm (est. 65-74° horizontal), rectilinear, no fisheye. DEEP focus (f/8-11 look): foreground leaves, birch, ferns and background trees all crisp, only very distant canopy softens by haze. Motion blur: the car shows slight horizontal smear (f690-f755, 1/60 s-ish); everything else static. **The car (red 911 GT3 RS-like, big fixed wing)** - Heading: screen-left, receding slightly (length shrinks). - Entry f646-648 at right edge (x 1.0, y 0.62) partly behind the foreground branches. Positions (centre x / apparent length): f660 0.92 / cropped; f690 0.70 / 440 px; f700 0.64 / 390; f720 0.55 / ~330; f740 0.47 / ~300; f755 0.40 / 270. y centre stays 0.59-0.61. Screen speed 14 px/frame (f660-690) easing to ~8 px/frame (f700-755): decelerating or turning away. Using ~28 mm and 4.6 m length: ≈15-25 km/h (est. 5 m/s). Smooth, no visible steering, body steady, slight wheel blur, partly hidden by ferns in front at f650-700 (low understory crosses the lower half of the car; wing and roof stay visible). - Paint reads dark red / orange-red, specular glints on the roof, glasshouse reflects bright sky. **Foreground:** large sharp white-backed (lit, silvery undersides) leaves + twigs bottom right (x 0.5-1.0, y 0.62-0.95), bright translucent lime leaf top right (x 0.84-0.95, y 0.03-0.25), curved birch trunk left. These are NOT moving past the lens; they are framing devices. **Environment:** open mixed deciduous woodland, many thin trunks (birch, beech), canopy fully closed above except pale sky slot top centre (x 0.5-0.6) and cloud top right; dense saplings; lots of ferns and low shrubs; forest floor brown leaf litter and lit green carpet; the track is a narrow (est. 3 m) pale gravel/dirt strip, indistinct, winding to the left behind a bright clearing at x 0.45-0.65, y 0.55-0.65. Dark brown fallen branches bottom centre (x 0.45-0.55, y 0.95-1.0) and bottom-left tree base. **Light:** sun high and behind the scene (from the far side, back-lit/3/4 back) shining through the canopy; leaves glow; bright dapples across the floor and clearing; shaded trunk faces. Dapple strength: medium-high contrast patches (~2-3 stops). Exposure: bright but retains detail, the middle clearing near clipping. Grade: fresh natural yellow-green, a touch cooler in shadows, moderate saturation, mild vignette; the red car is the only warm accent. **Composition:** thirds: birch on the left third (x 0.22-0.35), foreground leaf mass bottom right, car on the right-centre running along y ≈ 0.6 (lower-third line). Start (f620): no car, frame empty centre with the clearing at x 0.5. Middle (f690): car at x 0.70 (right-third line), 23% frame width. End (f755): car at x 0.40, small (14% width), near the clearing, leaving big empty space at right. Horizon/canopy line ~0.55. --- ## SHOT 3 — f0756 to f0860 (105 frames = 3.5 s, 25.2-28.67 s) **Camera** - RIGIDLY ATTACHED to the car (parented to the body). Mirror (x 0.27, y 0.37), B-pillar, window, rear arch, wheel (centre ≈ x 0.57, y 0.78) sit within a few pixels at f756, f790, f825 and f860. Background streams by instead. Not a follow with lag; zero sway relative to the car. - Position: just behind and beside the rear-left wheel, ~0.3 m above gravel, ~0.8-1.0 m from the body side, looking forward-diagonally at the door/mirror area (tilted up ~10-15°). Road visible at left curving ahead. Camera fixed in the car's frame, so the car "heading" is toward screen-left, speed est. 30-50 km/h from the wheel smear (spoke blur arc ~40° per 1/60 s ≈ 13 m/s upper bound) and the background streaking. - Slow subtle change of attitude: sky/cloud patch at top-left grows from f756 to f860 and the tree line drops by ~50-100 px (car pitches up slightly / road crests); background blur decreases a bit (car easing off or distance of trees increases). No roll beyond roughly 3-5°. **Lens:** very wide (est. 14-16 mm, ~100° H) with strong barrel/fisheye feel: trunks at left lean and bow outward, the road edge and the sill curve, the wheel is egg-shaped; magnified stretching toward corners. Depth of field: the car side from mirror to rear arch is sharp; background trees (bokeh not strong) are smeared by motion rather than defocus; gravel foreground (bottom-left) is blurred by motion/DoF. Motion blur: strong (180-270° shutter): wheel spokes smeared into a dark disc, background tree trunks horizontal streaks of 40-100 px, gravel at bottom-left radial streaks. **The car in frame** - Left (driver) side. Mirror at x 0.27, y 0.37; door handle (0.36, 0.48); window and roofline arc to top centre; fixed wing's top edge visible at top centre (x 0.52-0.63, y 0.08-0.11); rear arch big at x 0.45-0.8; rear wheel centre (0.57, 0.77), diameter ≈ 640+ px, so only the upper 70% is in frame; caliper red at lower right (x 0.69, y 0.92); side blade/rear vent behind the door; taillight slit far right (x 0.95, y 0.5); side sill carbon strip diagonal bottom-left. Front wheel visible beyond the door, small at x 0.22, y 0.62. - Wheel: rotating fast (blurred spokes, black centre-lock hub visible), forwards direction. - Steering: none visible. Suspension: no motion relative to camera (since the camera is mounted). - Paint: saturated orange-red (more orange than in S2); reflections: window shows blue sky, white cumulus, tree canopy (reflecting trees sweep through the glass), rear fender top has soft white cloud highlights with sun-dapple patterns drifting; door panel mirrors dark trunks. **Environment:** gravel track (grey-brown, fine stones, est. 3 m wide) at bottom-left with dapple of sun in the distance and dark shade in the foreground; fern clumps and orange leaf litter on the left bank (x 0-0.1, y 0.55-0.85); beyond, a green wall of thin trees (aspen/birch) with a few trunks, sky top-left/top-right pale with clouds (f825-860 more sky). Terrain flat to gently rising. **Light:** sun high, from the car's right/behind (so the visible left side is in open shade/skylight with dappled highlights), strong specular reflections of sky. Dapple: strong on the road ahead (bright patches vs deep shadow), moderate on the car. Exposure: contrasty; shadows near black (wheel arch, road in the foreground), highlights almost clipped on the fender. Grade: higher contrast and saturation than S2, rich red/orange, vivid greens, cool blue sky reflection, slight vignette (corners darker). **Composition:** start (f756): car dominates the right 75%; wheel and arch centre-right; road at left lower third leads the eye out; background trees/sky across the top 35%. Middle (f790-825): same with sky patch growing, the road straighter. End (f860): identical car placement, more sky top-left, trees behind brighter and steadier. Subject centre of mass at x 0.6-0.65, y 0.55. --- ## SHOT 4 — f0861 to f0959 (99 frames = 3.3 s, 28.7-31.97 s) (clip ends here, f0960 not included) **Camera** - Top-down (nadir, 85-90° down), est. 6-8 m above the ground, crane/jib/drone-like. Moves forward along the road direction (up-frame) while slightly DESCENDING / pushing in: scale of the ground litter grows ~10-15% from f861 to f959 (car width 380 px at f930 to 430 px at f959). Ground (stump/log) flows down at ~6 px/frame (~180 px/s ≈ 1 m/s at the ground, est.), the foreground bush at ~13 px/frame (about half the camera-ground distance, so a ~3 m tall bush top). Steady, linear to slow ease, no shake, no roll (log directions unchanged). The car appears ~190 px higher on screen between f930 and f959. - The shot starts with the lens pushing through the tops of a bush: big blurred leaves cover the right and upper right in f861-870, then clear by f875. **Lens:** ~24-35 mm, rectilinear, the road and the car edge seem to lean slightly because of perspective. DoF: ground sharp (f/5.6-8 look), the very close bush top (nearer to the lens) in f861-885 is soft with motion blur, the pale-green leaf mass top-left (f915-959) is crisp but slightly desaturated/greyed. Motion blur: moderate; only the nearest leaves are smeared. **The car (from above):** - First seen as a dark, grey-green-tinted roof sliver at bottom (x 0.6-0.75, y ≥ 0.96) behind foliage at f861-870; red roof at f887-895 in the lower-right corner (x 0.78-0.9, y 0.88-1.0); f900-929 rising: windscreen and wing glimpses; f930: occupying x 0.75-0.94, y 0.63-1.0; f959: x 0.76-0.98, y 0.46-1.0, bonnet with two black stripes, round headlights/ lamps visible, windscreen reflecting trees, roof black, nose pointing UP and slightly right (~10-15° off vertical, following the road). - Heading: up-frame (north), driving up the gravel road at the right. Relative to ground: ~12 px/frame i.e. ≈ 2 m/s (7 km/h, uncertain, a slow roll in this shot, i.e. much slower than the S3 feeling). Wheels/steering: not visible from above. No suspension motion. - Paint: saturated red-orange with black stripe, sunlit; reflections of treetops in the windscreen. **Occluders:** f861-885 big close bush at centre/right with blurred leaves (150-400 px) moving down-frame; later a second pale leaf mass (aspen-like round leaves, light grey-green, ~40-60 px each) at top-left that grows to fill the left 45% by f959. No leaf covers the car. **Environment:** forest floor from above: reddish-orange leaf litter, patches of fern/green herbs, small dead sticks, a grey weathered log/stump (x 0.55, y 0.3-0.6 at f959), a smaller white stick; gravel road on the right 20-25% of the frame (grey fine gravel, ~3-4 m wide, with long tree-shadow bars), shadowed edge on the left of the car. Dark tree trunks at top. No sky. **Light:** sun high, from the right/behind (shadows of trees fall as horizontal bars on the road toward the lower-left; the car throws a shadow on the left side). Dapple: strong (bright orange litter pools vs dark green shade). Exposure: darker and richer than the wide shots; mid-low key. Grade: warm earthy oranges and browns, desaturated cool greens, high local contrast. **Composition:** start (f861): bush mass filling the centre, orange ground showing at left, gravel and the car's roof sliver at the bottom right corner. Middle (f895-900): gravel road enters from top right edge, car corner at bottom right. End (f959): car on the lower right third intersection (x 0.85, y 0.75); the road along the right edge; the leaf mass top-left; open ground at centre. Subject at bottom right with empty space on the left (diagonal composition). --- ## Clip end (32.0 s) The clip ends at f959 mid-move: the top-down camera is still travelling forward/descending and the ground still flowing down at ~6 px/frame; the car is still rising in frame. No fade, dip, flash, text or ramp: next frame (f960) is beyond this clip. All transitions in this clip are straight hard cuts (S1>S2 at f620 and S2>S3 at f756, S3>S4 at f861); the cuts are on action: car heading left in S2 then S3 (same screen direction), then the viewpoint changes completely at f861. ## Recreation notes - S1: keyframe a slow right-truck (~5-10 cm/s, linear) at 0.6 m height, 40-50 mm f/2.5, place a few dark leaf cards 5-20 cm from the lens for blur. - S2: tripod 1.2 m, 26 mm, f/11, birch trunk at left third, silvery leaf clump foreground bottom right; car drive path left, 20 km/h decelerating, lane ending at clearing. Car partly hidden behind ferns. - S3: parent camera to the car's rear-left quarter 0.3 m high, 15 mm with strong barrel (or fisheye ~110°), shutter 270°, spinning wheel with motion blur, dapple on the gravel. - S4: top-down camera 7 m, drifts forward 1 m/s with a slow 10% descent, car with 2 m/s forward speed starting below frame, a bush just under the lens at the start.skills/blender-forest/notes/PLAN.md3.4 KB286e025b4c1cupstream ↗
# Plan: recreate first 16 s of "Blender Hyper-Realistic Forest" (roe.num77) on RTX 5070 Reference: research/frames/f0000-f0900.jpg (30 fps, 1080p), research/a0.jpg a1.jpg b.jpg, research/frames/transcript.txt Project file from him: NONE. Only assets (376-384). Final forest .blend and the cabin are not given. Cabin is "tell me in the comments" in the video. ## Shots (cuts at 3.53 / 7.23 / 11.27 / 14.77 / 17.63 s) = 480 frames for 0-16 s @30 1. 0.0-3.5 low cam over floor, tilted down: fallen log, ferns, orange leaf litter. Slide + tilt up. 2. 3.5-7.2 eye level ~0.5 m, ferns in front, rocks, gravel path far, dark trunk left. Slow glide sideways. 3. 7.2-11.3 low beside path, gravel curves right, birches, dappled sun. Slow forward glide. 4. 11.3-14.8 two black A-frame cabins, wood deck, gravel path, blue sky + clouds. Slow push-in. 5. 14.8-17.6 close on cabin glass (warm lights), firewood rack, deck edge. (we stop at 16.0) ## Stages (one script each in scripts/, resumable, save .blend after each) A. Unpack + audit. DONE except textures (379). B. build_world.py Terrain (his Remeshed Terrain) + leaf-soil material (map 50, disp 0.2, hue/sat 1.2, rough 1). HDRI strength 2.25, ground hidden (z 0.25), Filmic/High Contrast (fall back to AgX look if 5.2 lacks Filmic), exposure 1.5, gamma 0.9. Light-path trick (camera ray -> sky strength 1, lighting 2.25). Sun power 10, warm, rot 24/-53/148. C. scatter.py OUR OWN scatter (Geometry Nodes built by script, no Geo-Scatter). Layers with his numbers: birch x11 (dens .06, vert 0.85, scale 1.6), young trees x2 (.05), large shrubs (.5), small shrubs (hand-placed ring around cabin), ferns (5, scale 1.3-1.87), branches (1.85), debris (3), leaves (200, 70% cull), ground plants (50% cull), rocks, fallen logs. Masks: vertex group "center" (cabin+path cleared), bezier-area, color attribute for leaves. Seeded so renders are repeatable. D. build_path.py Bezier -> mesh -> boolean with duplicate terrain -> gravel material (2 gravel textures mixed by 'middle' vertex mask, bump 0.1, disp 0.05). E. build_cabins.py OUR OWN cabins from frame references: two black charred-wood A-frames, slate roof, big glass front with warm interior (stove, furniture, lamps from plant library), wood deck (planks, bevel .003, array), rusty firewood rack, chimney pipe. F. camera.py 5 cameras/shots keyframed to match timings and moves, subtle handheld noise, shallow DOF, motion blur on. G. Look-dev stills (low res), compare side by side with reference frames, tune until it matches. Loop here. H. Render + export. ## Render budget (RTX 5070 12 GB, Cycles OptiX, denoise) - Look-dev stills: 960x540, 32 samples, ~10-30 s each. - Test frame: 1080p, ~128 samples, estimate 30-90 s/frame (dense foliage). 480 frames = ~4-12 h. Will measure on 1 frame per shot first and report real number before any full render. - Per-shot frame ranges, PNG sequence (power-cut safe, resume), then ffmpeg mp4. 1080p for scenes, 2K only for final stitched video. - Will ask before starting any long render. ## Safety / rules - Blender always headless with --disable-autoexec on his files. Nothing of his run except as data. - Defender clean, no scripts inside (see SECURITY.md). Rig-a-Car and Porsches untouched until needed. - Own code only: scatter, cabins, camera, rig (later) are written by us. ## Open items - textures.rar (379) not downloaded yet. - Decide 1080p vs lower for the full 16 s after the timing test.skills/blender-forest/notes/SECURITY.md2.1 KBc00e76a0053bupstream ↗
# Security check of roe.num77 downloads (2026-10-19) Source: the tutorial's free asset posts, unpacked to blender-forest\assets (one folder per archive, nothing run). | Check | Result | |---|---| | Windows Defender custom scan (archives + unpacked folder), signatures updated 2026-10-19 | clean, no detections | | Archive contents (7z listing): any .exe .dll .bat .cmd .ps1 .vbs .js .jar .msi .scr .lnk .py .sh | none in the 5 archives used | | Unpacked files are only: .blend, .hdr, .jpg, .png, .tif, .txt | yes | | Terrain.blend and Plant library .blend opened headless with --disable-autoexec: embedded text/script blocks | 0 | | Scripted drivers / OSL script nodes in them | 0 | | Linked external .blend libraries | 0 | Car files (rigacar.rar, Porsche Carrera.rar, Porsche Gtr_Rigged.rar): checked later, see the addendum below. Only needed after the 17 s mark. Do NOT install the add-on (your own-code-only rule). Rules kept: Blender always run with --disable-autoexec on his files; no add-ons from him installed. Textures (post 379, textures.rar 1.4 GB): Defender clean; contents only 374 jpg, 27 png, 2 tif, 2 hdr. Unpacked to `Plant models\textures\`. Plant library now resolves 415 of 416 images; the 1 missing is a junk path `C:\Map #1781324284` (leftover from the creator's export). ## Addendum: car files (post 382-384), unpacked to blender-forest\quarantine (not installed) - Defender scan of the download folder already covered them: clean. - Porsche Carrera.blend and Porsche Gtr_Rigged.blend, opened headless --disable-autoexec: 0 text/script blocks, 0 scripted drivers, 0 OSL nodes, 0 linked libraries. - rigacar-Jeanyan3D-Fix-v3.zip = Rig-A-Car add-on (open source by digicreatures, "Jeanyan3D fix" is a third-party patch). Source read: 4 .py files, only imports bpy, bpy_extras, math, mathutils, re, itertools, importlib. No network, subprocess, os, exec/eval, file writes. Only URLs are doc/issue links in metadata. - Caveat: the zip also ships precompiled .pyc files (cannot be read). Do not install as-is; if we ever use it, delete the __pycache__ folder first. Not needed for 0-16 s anyway.README.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/mromidev-claude-blender-skills-skills-blender-forest.md