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:
- Using modulo arithmetic instead of
torch.roll()for consistent wraparound - Choosing periods that divide 600 for perfect looping
- 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