Skip to content
Merged
589 changes: 511 additions & 78 deletions animate/scenes/blender/render_kpg.py

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions animate/scenes/blender/sync_kpg_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Load the current K–Pg cinema into a Blender window.

.venv/bin/python animate/scenes/blender/sync_kpg_preview.py

Writes the job, then opens Blender (not --background) and rebuilds the scene.
Does not render the GIF. Scrub the timeline after it loads.
"""

from __future__ import annotations

import os
import shutil
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parents[3]
JOB_PATH = Path('/tmp/earth_kpg_dark_job.json')
PREVIEW = Path('/tmp/solsys_kpg_preview')
Comment thread
ThomasAFink marked this conversation as resolved.
Outdated
DUMMY_PNG = bytes.fromhex(
'89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de'
'0000000c4944415408d763f8cf0000010101005f9d4e0e0000000049454e44ae426082'
)
PREVIEW_FRAME = 500


def _writeJob() -> None:
os.environ.setdefault('MPLCONFIGDIR', '/tmp/mpl-solsys')
if str(REPO) not in sys.path:
sys.path.insert(0, str(REPO))
from animate.scenes.blender.flyby_scene import writeFlybyJob
from animate.scenes.kpg_cinematic import (
DEFAULT_EVENT_CSV,
buildImpactSamples,
buildKpgJob,
loadImpactEvent,
)

event = loadImpactEvent(REPO / DEFAULT_EVENT_CSV)
PREVIEW.mkdir(parents=True, exist_ok=True)
job = buildKpgJob(event, buildImpactSamples(event), theme='dark', framesDirectory=PREVIEW)
writeFlybyJob(job, JOB_PATH)
print(f'Wrote {JOB_PATH} ({len(job["frames"])} frames)', flush=True)


def _applyInBlender() -> None:
import importlib.util

import bpy # type: ignore[import-not-found]

bpy.app.handlers.frame_change_pre.clear()
Comment thread
ThomasAFink marked this conversation as resolved.
Outdated
script = REPO / 'animate' / 'scenes' / 'blender' / 'render_kpg.py'
spec = importlib.util.spec_from_file_location('solsys_render_kpg', script)
if spec is None or spec.loader is None:
raise RuntimeError(f'Cannot load {script}')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
job = module.loadKpgJob(JOB_PATH)
PREVIEW.mkdir(parents=True, exist_ok=True)
job['outputDirectory'] = str(PREVIEW)
job['stillFrames'] = [PREVIEW_FRAME]
(PREVIEW / f'frame_{PREVIEW_FRAME:04d}.png').write_bytes(DUMMY_PNG)
original = bpy.ops.render.render

def _skip(*_args: object, **_kwargs: object) -> set[str]:
return {'FINISHED'}

bpy.ops.render.render = _skip
try:
module.applyKpgJobInBlender(job)
finally:
bpy.ops.render.render = original
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
bpy.context.scene.frame_set(PREVIEW_FRAME)
print(
f'Synced Blender frame={PREVIEW_FRAME} end={int(bpy.context.scene.frame_end)}',
flush=True,
)


def _blenderExecutable() -> str:
found = shutil.which('blender')
if found:
return found
mac = Path('/Applications/Blender.app/Contents/MacOS/Blender')
if mac.is_file():
return str(mac)
raise RuntimeError('blender not found on PATH')


def main() -> int:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
try:
import bpy # type: ignore[import-not-found]
except ImportError:
bpy = None
if bpy is not None:
_applyInBlender()
return 0
_writeJob()
blender = _blenderExecutable()
os.execv(blender, [blender, '--python', str(Path(__file__).resolve())])


if __name__ == '__main__':
raise SystemExit(main())
43 changes: 24 additions & 19 deletions animate/scenes/kpg_cinematic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

Late Cretaceous Earth, a cinema-scale rock enters, hits the Yucatán, and a
drawn cascade (crust tear, ejecta, tsunami, wildfire, soot) plays out on that
globe. Earth is already turning slowly; after contact the spin picks up, the site
glow dies, and a cinema-compressed soot / twilight / recovery beat follows.
globe. Earth is already turning slowly; after contact the spin picks up, then
turns many times under the ash, the site glow dies, and a cinema-compressed
soot / twilight / recovery beat follows.
Land stays brown when the soot lifts and re-greens slowly. The map is an artist
reconstruction, not a palaeomap. The rock is enlarged; true scale is 10/12742.
This is a cinema drawing, not a hydro or climate model.
Expand Down Expand Up @@ -36,7 +37,7 @@
KPG_EARTH_PACK = Path('data/textures/bodies/earth_kpg')

ANIMATION_FPS = 20
ANIMATION_FRAMES = 720
ANIMATION_FRAMES = 900
RENDER_RESOLUTION = 960
GALLERY_SIZE = 568

Expand All @@ -54,13 +55,14 @@
SPIN_END = 430
SOOT_END = 535
TWILIGHT_END = 585
SOOT_CLEAR_START = 645
HOLD_END = IMPACT_FRAME
CAMERA_FOLLOW_END = 360
SLAM_FRAMES = 16
SPIN_SLOW_RAD_PER_FRAME = math.tau / 1600.0
SPIN_FAST_RAD_PER_FRAME = math.tau / 180.0
SPIN_FAST_RAD_PER_FRAME = math.tau / 90.0
SPIN_RAMP_FRAMES = 100
SOOT_SPIN_RAD_PER_FRAME = math.tau / 70.0
SOOT_SPIN_RAD_PER_FRAME = math.tau / 26.0
RECOVERY_SPIN_RAD_PER_FRAME = math.tau / 520.0
SOOT_SPIN_RAMP = 36
RECOVERY_SPIN_RAMP = 55
Expand Down Expand Up @@ -426,11 +428,11 @@ def diebackEnvelope(frame: int) -> float:
if frame < IMPACT_FRAME + 24:
return 0.0
killed = 0.97 * smoothStep((frame - IMPACT_FRAME - 24) / 70.0)
barrenHold = TWILIGHT_END + 48
barrenHold = SOOT_CLEAR_START + 50
if frame < barrenHold:
return killed
recover = smoothStep((frame - barrenHold) / float(ANIMATION_FRAMES - 1 - barrenHold))
return killed * (1.0 - 0.78 * recover)
return killed * (1.0 - 0.97 * recover)


def crustTearEnvelope(frame: int) -> float:
Expand Down Expand Up @@ -458,17 +460,19 @@ def falloutEnvelope(frame: int) -> float:


def lightingEnvelope(frame: int, frameCount: int) -> tuple[float, float]:
# Floor stays high enough that EEVEE still reads the ash sheet. 0.02
# turns the winter globe into a black silhouette with only the limb left.
soot = sootEnvelope(frame)
floor = 0.18
if frame < SPIN_END:
return 1.0 - 0.55 * soot, soot
if frame < TWILIGHT_END:
start = 1.0 - 0.55 * sootEnvelope(SPIN_END)
floor = 0.02
mix = smoothStep((frame - SPIN_END) / float(TWILIGHT_END - SPIN_END))
return start + (floor - start) * mix, soot
floor = 0.02
recovered = 0.88
mix = smoothStep((frame - TWILIGHT_END) / max(frameCount - 1 - TWILIGHT_END, 1.0))
return 1.0 - 0.70 * soot, soot
if frame < SOOT_CLEAR_START:
start = 1.0 - 0.70 * sootEnvelope(SPIN_END)
mix = smoothStep((frame - SPIN_END) / float(max(TWILIGHT_END - SPIN_END, 1)))
dimmed = start + (floor - start) * min(mix, 1.0)
return dimmed, soot
recovered = 0.98
mix = smoothStep((frame - SOOT_CLEAR_START) / max(frameCount - 1 - SOOT_CLEAR_START, 1.0))
return floor + (recovered - floor) * mix, soot


Expand Down Expand Up @@ -500,10 +504,10 @@ def sootEnvelope(frame: int) -> float:
return settled + (0.96 - settled) * smoothStep(
(frame - SPIN_END) / float(SOOT_END - SPIN_END)
)
if frame < TWILIGHT_END:
if frame < SOOT_CLEAR_START:
return 0.96
return 0.96 - 0.72 * smoothStep(
(frame - TWILIGHT_END) / float(ANIMATION_FRAMES - 1 - TWILIGHT_END)
return 0.96 - 0.94 * smoothStep(
(frame - SOOT_CLEAR_START) / float(ANIMATION_FRAMES - 1 - SOOT_CLEAR_START)
)


Expand Down Expand Up @@ -1483,6 +1487,7 @@ def renderKpgCinematicAnimations(
__all__ = [
'ANIMATION_FRAMES',
'ACT_BOUNDARIES',
'SOOT_CLEAR_START',
'CAMERA_FOLLOW_END',
'IMPACT_FRAME',
'ImpactEvent',
Expand Down
Loading