Files
ARMSX3/tools/render_boot_animation.py
jpolo1224 1eb63137e3 ARMSX3: Android port of RPCS3, proof of concept
Adds an Android build of the RPCS3 core plus a Compose UI, and fixes several
things that stopped it working on ARM64.

Renderer:
- Emit concrete bounds for runtime sized arrays in uniform blocks when
  VK_EXT_shader_uniform_buffer_unsized_array is missing. Adreno does not have
  the extension, so every game pipeline failed with VK_ERROR_UNKNOWN and only
  overlays drew.
- Probe and request that extension properly instead of chaining its feature
  struct unconditionally.
- Hand VMA the Vulkan function pointers it needs under VK_NO_PROTOTYPES.
- Rebuild the surface and swapchain when the window is lost instead of killing
  the RSX thread.
- Only create a GLES context when the GL renderer is actually selected.

SPU:
- Sum instead of taking an absolute difference in the ARM64 block verification
  checksum. The difference collides on the near identical job binaries an SPU
  job manager streams through one local store address, so a cached block could
  run against another job's code.

Threading:
- Implement thread affinity on Android using sched_setaffinity.
- Add an ARM big.LITTLE core arrangement so SPU and RSX threads land on the
  fast cores.

Misc:
- Detect the host CPU for the LLVM JIT instead of pinning cortex-a34.
- Fall back to the default audio device when cubeb cannot enumerate.
2026-08-06 08:59:52 -04:00

365 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Render the ARMSX3 animated boot logo and its preview assets."""
from __future__ import annotations
import argparse
import math
import subprocess
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
def clamp01(value: float) -> float:
return max(0.0, min(1.0, value))
def smoothstep(edge0: float, edge1: float, value: float) -> float:
if edge0 == edge1:
return float(value >= edge1)
x = clamp01((value - edge0) / (edge1 - edge0))
return x * x * (3.0 - 2.0 * x)
def ease_out_cubic(value: float) -> float:
x = clamp01(value)
return 1.0 - (1.0 - x) ** 3
def ease_out_back(value: float) -> float:
x = clamp01(value)
c1 = 1.70158
c3 = c1 + 1.0
return 1.0 + c3 * (x - 1.0) ** 3 + c1 * (x - 1.0) ** 2
def alpha_composite_rgb(base: np.ndarray, layer: Image.Image) -> np.ndarray:
rgba = np.asarray(layer, dtype=np.float32)
alpha = rgba[..., 3:4] / 255.0
return base * (1.0 - alpha) + rgba[..., :3] * alpha
class Renderer:
def __init__(self, source: Path, size: int, fps: int, duration: float) -> None:
self.size = size
self.fps = fps
self.duration = duration
self.source = Image.open(source).convert("RGB")
self.logo = self._extract_logo()
self.logo_mask = self.logo.getchannel("A")
self.background = self._make_background()
self.yy, self.xx = np.mgrid[0:size, 0:size].astype(np.float32)
self.rng = np.random.default_rng(3303)
self.particles = self._make_particles()
def _extract_logo(self) -> Image.Image:
src = np.asarray(self.source, dtype=np.float32)
height, width = src.shape[:2]
# Model the vertical purple gradient from the quiet image edges. A low
# percentile rejects the bright XMB ribbon and particle highlights.
edge_samples = np.concatenate((src[:, :64], src[:, width - 64 :]), axis=1)
background_by_row = np.percentile(edge_samples, 28, axis=1)
x0, x1 = 88, 424
y0, y1 = 132, 372
crop = src[y0:y1, x0:x1]
background = background_by_row[y0:y1, None, :]
delta = np.clip(crop - background, 0.0, 255.0)
delta_luma = 0.30 * delta[..., 0] + 0.58 * delta[..., 1] + 0.12 * delta[..., 2]
# The supplied mark is substantially brighter than its backdrop. Keep
# its soft bevel/glow while rejecting the rectangular purple crop.
alpha = np.clip((delta_luma - 24.0) / 68.0, 0.0, 1.0)
alpha = alpha * alpha * (3.0 - 2.0 * alpha)
alpha[alpha < 0.035] = 0.0
# Keep the extraction tightly bounded to the supplied numeral. The
# source ribbon continues beneath it and would otherwise reveal the
# rectangular limits of this crop when animated over a new backdrop.
alpha[229:, :] = 0.0
alpha[:, :9] = 0.0
alpha[:, 328:] = 0.0
alpha_img = Image.fromarray(np.uint8(alpha * 255.0), "L")
alpha_img = alpha_img.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.GaussianBlur(1.1))
crop_img = Image.fromarray(np.uint8(np.clip(crop, 0.0, 255.0)), "RGB")
crop_img.putalpha(alpha_img)
return crop_img
def _make_background(self) -> np.ndarray:
size = self.size
y = np.linspace(0.0, 1.0, size, dtype=np.float32)[:, None, None]
x = np.linspace(-1.0, 1.0, size, dtype=np.float32)[None, :, None]
top = np.array([22.0, 5.0, 42.0], dtype=np.float32)[None, None, :]
bottom = np.array([111.0, 31.0, 226.0], dtype=np.float32)[None, None, :]
base = top * (1.0 - y) + bottom * y
base = np.broadcast_to(base, (size, size, 3)).copy()
# A broad center bloom and restrained edge vignette evoke the XMB
# backdrop without competing with the mark.
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
cx, cy = size * 0.50, size * 0.60
radius = np.sqrt(((xx - cx) / size) ** 2 + ((yy - cy) / size) ** 2)
bloom = np.exp(-(radius / 0.48) ** 2)[..., None]
base += bloom * np.array([24.0, 13.0, 52.0], dtype=np.float32)
vignette = np.clip(np.abs(x) ** 1.7 * 18.0, 0.0, 18.0)
base -= vignette
return np.clip(base, 0.0, 255.0)
def _make_particles(self) -> list[tuple[float, ...]]:
particles: list[tuple[float, ...]] = []
for _ in range(88):
particles.append(
(
float(self.rng.uniform(-0.15, 1.15)),
float(self.rng.uniform(0.025, 0.085)),
float(self.rng.normal(0.0, 0.032)),
float(self.rng.uniform(0.8, 2.6)),
float(self.rng.uniform(0.0, math.tau)),
float(self.rng.uniform(0.45, 1.0)),
)
)
return particles
def wave_y(self, x: np.ndarray | float, t: float, band: int = 0) -> np.ndarray | float:
normalized_x = np.asarray(x) / self.size
phase = t * (0.18 + band * 0.035)
y = self.size * (0.625 + band * 0.018)
y += self.size * (0.038 + band * 0.008) * np.sin(math.tau * (normalized_x * 0.72 - phase) + band * 1.15)
y += self.size * 0.014 * np.sin(math.tau * (normalized_x * 1.55 + t * 0.09) + band * 0.7)
return y
def _draw_ribbons(self, t: float, opacity: float) -> Image.Image:
size = self.size
glow = Image.new("RGBA", (size, size), (0, 0, 0, 0))
glow_draw = ImageDraw.Draw(glow)
core = Image.new("RGBA", (size, size), (0, 0, 0, 0))
core_draw = ImageDraw.Draw(core)
colors = ((231, 217, 255), (167, 111, 255), (255, 255, 255))
for band in range(3):
points = []
for x in range(-16, size + 17, 5):
points.append((x, float(self.wave_y(x, t, band))))
glow_alpha = int(opacity * (92 - band * 18))
core_alpha = int(opacity * (178 - band * 25))
glow_draw.line(points, fill=(*colors[band], glow_alpha), width=10 - band * 2, joint="curve")
core_draw.line(points, fill=(*colors[band], core_alpha), width=max(1, 3 - band), joint="curve")
glow = glow.filter(ImageFilter.GaussianBlur(8.0))
return Image.alpha_composite(glow, core)
def _draw_particles(self, t: float, opacity: float) -> Image.Image:
size = self.size
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
for x0, speed, offset, radius, phase, brightness in self.particles:
x_norm = ((x0 + speed * t) % 1.3) - 0.15
x = x_norm * size
y = float(self.wave_y(x, t, 0)) + offset * size
y += math.sin(t * 1.3 + phase) * size * 0.006
twinkle = 0.30 + 0.70 * (0.5 + 0.5 * math.sin(t * 3.4 + phase)) ** 2
alpha = int(255 * opacity * brightness * twinkle)
r = radius * (0.75 + 0.30 * twinkle)
draw.ellipse((x - r, y - r, x + r, y + r), fill=(249, 239, 255, alpha))
glow = layer.filter(ImageFilter.GaussianBlur(2.5))
glow.putalpha(glow.getchannel("A").point(lambda a: min(180, a * 2)))
return Image.alpha_composite(glow, layer)
def _draw_logo_flash(self, t: float) -> Image.Image:
"""Sweep a soft highlight over the original numeral without redrawing it."""
progress = (t - 0.72) / 1.08
if progress <= 0.0 or progress >= 1.0:
return Image.new("RGBA", (self.size, self.size), (0, 0, 0, 0))
scale = self.size / self.source.width
mask_width = int(round(self.logo_mask.width * scale))
mask_height = int(round(self.logo_mask.height * scale))
mask = self.logo_mask.resize((mask_width, mask_height), Image.Resampling.LANCZOS)
local_x = np.arange(mask_width, dtype=np.float32)[None, :]
local_y = np.arange(mask_height, dtype=np.float32)[:, None]
center = (-0.28 + progress * 1.62) * mask_width
stripe = np.exp(-((local_x + local_y * 0.36 - center) / (mask_width * 0.052)) ** 2)
envelope = math.sin(math.pi * progress) ** 0.65
mask_np = np.asarray(mask, dtype=np.float32) / 255.0
stripe_alpha = np.uint8(np.clip(stripe * mask_np * envelope * 178.0, 0.0, 255.0))
flash = Image.new("RGBA", (mask_width, mask_height), (255, 250, 255, 0))
flash.putalpha(Image.fromarray(stripe_alpha, "L").filter(ImageFilter.GaussianBlur(1.4 * scale)))
glow = flash.filter(ImageFilter.GaussianBlur(5.5 * scale))
glow.putalpha(glow.getchannel("A").point(lambda a: min(150, int(a * 1.25))))
layer = Image.new("RGBA", (self.size, self.size), (0, 0, 0, 0))
x = int(round(88 * scale))
y = int(round(132 * scale))
layer.alpha_composite(glow, (x, y))
layer.alpha_composite(flash, (x, y))
return layer
def _logo_layer(self, t: float, opacity: float) -> Image.Image:
size = self.size
progress = clamp01((t - 0.42) / 0.95)
settle = ease_out_back(progress)
scale = 0.72 + 0.28 * settle
scale *= 1.0 + 0.006 * math.sin(max(0.0, t - 1.35) * 1.55) * math.exp(-max(0.0, t - 1.35) * 0.65)
target_width = int(size * 0.635 * scale)
target_height = max(1, int(target_width * self.logo.height / self.logo.width))
logo = self.logo.resize((target_width, target_height), Image.Resampling.LANCZOS)
blur_radius = (1.0 - ease_out_cubic(progress)) * 18.0
if blur_radius > 0.2:
logo = logo.filter(ImageFilter.GaussianBlur(blur_radius))
logo_alpha = logo.getchannel("A").point(lambda a: int(a * opacity))
logo.putalpha(logo_alpha)
x = (size - target_width) // 2
y = int(size * 0.49 - target_height * 0.50)
# A subtle depth shadow makes the bevel read when the ribbons pass
# behind it.
shadow_mask = logo_alpha.filter(ImageFilter.GaussianBlur(7.0))
shadow = Image.new("RGBA", (size, size), (0, 0, 0, 0))
shadow_stamp = Image.new("RGBA", logo.size, (12, 3, 30, 0))
shadow_stamp.putalpha(shadow_mask.point(lambda a: int(a * 0.33)))
shadow.alpha_composite(shadow_stamp, (x + 4, y + 10))
result = shadow
result.alpha_composite(logo, (x, y))
# A diagonal glint traverses only the mark during its final lock-in.
glint_progress = clamp01((t - 1.18) / 0.90)
if 0.0 < glint_progress < 1.0:
local_x = np.arange(target_width, dtype=np.float32)[None, :]
local_y = np.arange(target_height, dtype=np.float32)[:, None]
center = (-0.32 + glint_progress * 1.65) * target_width
stripe = np.exp(-((local_x + local_y * 0.38 - center) / (target_width * 0.055)) ** 2)
alpha_np = np.asarray(logo_alpha, dtype=np.float32) / 255.0
stripe_alpha = np.uint8(np.clip(stripe * alpha_np * 132.0, 0.0, 255.0))
glint = Image.new("RGBA", (target_width, target_height), (255, 250, 255, 0))
glint.putalpha(Image.fromarray(stripe_alpha, "L").filter(ImageFilter.GaussianBlur(1.2)))
result.alpha_composite(glint, (x, y))
return result
def frame(self, t: float) -> Image.Image:
# Keep the supplied artwork completely intact. Only the XMB ribbon
# and its particles move; there is no logo extraction, reveal, scale,
# or fade layer that can introduce seams through the numeral.
source_frame = self.source.resize((self.size, self.size), Image.Resampling.LANCZOS)
bg = np.asarray(source_frame, dtype=np.float32).copy()
ribbon = self._draw_ribbons(t, 0.72)
bg = alpha_composite_rgb(bg, ribbon)
particles = self._draw_particles(t, 0.78)
bg = alpha_composite_rgb(bg, particles)
flash = self._draw_logo_flash(t)
bg = alpha_composite_rgb(bg, flash)
return Image.fromarray(np.uint8(np.clip(bg, 0.0, 255.0)), "RGB")
def render_video(renderer: Renderer, ffmpeg: Path, output: Path, output_size: int) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
command = [
str(ffmpeg),
"-hide_banner",
"-loglevel",
"error",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-s",
f"{renderer.size}x{renderer.size}",
"-r",
str(renderer.fps),
"-i",
"-",
"-an",
"-vf",
f"scale={output_size}:{output_size}:flags=lanczos",
"-c:v",
"libx264",
"-preset",
"slow",
"-crf",
"17",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-y",
str(output),
]
process = subprocess.Popen(command, stdin=subprocess.PIPE)
assert process.stdin is not None
total_frames = int(round(renderer.duration * renderer.fps))
try:
for frame_index in range(total_frames):
t = frame_index / renderer.fps
process.stdin.write(renderer.frame(t).tobytes())
finally:
process.stdin.close()
if process.wait() != 0:
raise RuntimeError("ffmpeg failed while encoding the boot animation")
def render_preview(ffmpeg: Path, video: Path, output: Path) -> None:
filter_graph = (
"fps=12,scale=640:640:flags=lanczos,split[s0][s1];"
"[s0]palettegen=max_colors=192:stats_mode=diff[p];"
"[s1][p]paletteuse=dither=bayer:bayer_scale=3"
)
subprocess.run(
[
str(ffmpeg),
"-hide_banner",
"-loglevel",
"error",
"-i",
str(video),
"-lavfi",
filter_graph,
"-loop",
"0",
"-y",
str(output),
],
check=True,
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--ffmpeg", required=True, type=Path)
parser.add_argument("--render-size", type=int, default=640)
parser.add_argument("--output-size", type=int, default=1280)
parser.add_argument("--fps", type=int, default=30)
parser.add_argument("--duration", type=float, default=5.0)
args = parser.parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
renderer = Renderer(args.source, args.render_size, args.fps, args.duration)
video = args.output_dir / "armsx3-boot-xmb.mp4"
poster = args.output_dir / "armsx3-boot-xmb-poster.png"
preview = args.output_dir / "armsx3-boot-xmb-preview.gif"
render_video(renderer, args.ffmpeg, video, args.output_size)
final_frame = renderer.frame(args.duration - 1.0 / args.fps)
final_frame.resize((args.output_size, args.output_size), Image.Resampling.LANCZOS).save(poster)
render_preview(args.ffmpeg, video, preview)
print(video)
print(poster)
print(preview)
if __name__ == "__main__":
main()