2 patterns-earthbound-HDMA-Scanline-Loop
Balazs Horvath edited this page 2026-04-18 11:13:12 +02:00

HDMA Scanline Loop

Fixed HDMA pattern with per-row shifts that loop seamlessly over 600 frames.

Loop Guarantee

Shift (30 frames) and color (60 frames) periods divide 600. Modulo arithmetic ensures wraparound consistency.

Mathematical Formula


\text{shift} = \sin(y \cdot k + \frac{2\pi t}{30}) \cdot \text{max}_{\text{shift}}

\text{col}_{\text{indices}} = (x - \text{shift}) \bmod \text{width}

Where:

  • Shift period: 30 frames
  • Color period: 60 frames
  • Modulo arithmetic ensures wraparound

How It Works

This pattern improves on the basic HDMA scanline shift by:

  1. Using modulo arithmetic instead of torch.roll() for consistent wraparound
  2. Choosing periods that divide 600 for perfect looping
  3. Using advanced indexing for efficient shifting

Implementation

import torch

width, height = 512, 512
fps = 60
duration = 10
total_frames = fps * duration
frames = []

for t in range(total_frames):
    # Create coordinate grids
    x = torch.linspace(0, 2*torch.pi, width)
    y = torch.linspace(0, 2*torch.pi, height)
    X, Y = torch.meshgrid(x, y, indexing='ij')
    
    # HDMA-style scanline shift with 30-frame period
    shift_amount = (torch.sin(Y * 2 + t * (2*torch.pi/30)) * 20).long()
    
    # Create shifted indices using modulo arithmetic
    col_indices = torch.arange(width).unsqueeze(0).expand(height, -1)
    shifted_indices = (col_indices - shift_amount) % width
    
    # Apply shift using advanced indexing
    shifted = X[:, shifted_indices]
    
    # Color with 60-frame period
    r = torch.sin(shifted + t * (2*torch.pi/60))
    g = torch.sin(shifted + t * (2*torch.pi/60) + 2*torch.pi/3)
    b = torch.sin(shifted + t * (2*torch.pi/60) + 4*torch.pi/3)
    
    rgb = torch.stack([r, g, b], dim=-1)
    rgb = ((rgb + 1) / 2).clamp(0, 1)
    frames.append(rgb)

output_image = torch.stack(frames, dim=0)

Line-by-Line Explanation

shift_amount = (torch.sin(Y * 2 + t * (2*torch.pi/30)) * 20).long()

Calculates shift amount with period of 30 frames. .long() converts to integer for indexing.

col_indices = torch.arange(width).unsqueeze(0).expand(height, -1)

Creates a 2D array of column indices: [[0, 1, 2, ..., width-1], [0, 1, 2, ..., width-1], ...]

shifted_indices = (col_indices - shift_amount) % width

Subtracts shift amount and applies modulo. The % width ensures wraparound: shifting past width returns to 0.

shifted = X[:, shifted_indices]

Advanced indexing: for each row, selects columns according to shifted_indices. This is more explicit than torch.roll().

Mathematical Insight

Why Modulo Arithmetic?

(col_indices - shift) % width

This ensures:

  • Shifting by width pixels wraps to 0
  • Shifting by -1 pixel wraps to width-1
  • Any shift wraps correctly within [0, width-1]

Unlike torch.roll(), which uses circular shift internally, this explicit modulo makes the wraparound behavior clear and mathematically verifiable.

Why These Periods?

  • 30: 600 / 30 = 20 (shift cycles)
  • 60: 600 / 60 = 10 (color cycles)

Both divide 600 evenly, ensuring the pattern returns to its starting state.

Customization

Larger Shift

shift_amount = (torch.sin(Y * 2 + t * (2*torch.pi/30)) * 40).long()

Different Shift Period

shift_amount = (torch.sin(Y * 2 + t * (2*torch.pi/40)) * 20).long()

Use torch.roll() Instead

shifted = torch.roll(X, shift_amount, dims=1)

Performance Notes

  • Advanced indexing is efficient
  • Modulo arithmetic is fast
  • All operations are vectorized

Loop Verification

# After generating frames
first_frame = output_image[0]
last_frame = output_image[-1]
diff = torch.abs(first_frame - last_frame).max()
print(f"Max difference: {diff.item()}")
# Should be close to 0