-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Jack Parmer
authored and
Jack Parmer
committed
Jun 18, 2024
1 parent
a496d18
commit 56e0a79
Showing
14 changed files
with
402 additions
and
0 deletions.
There are no files selected for viewing
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,275 @@ | ||
import numpy as np | ||
from PIL import Image, ImageDraw | ||
import io | ||
import matplotlib.pyplot as plt | ||
from IPython.display import display, Image as IPImage | ||
from noise import pnoise3 | ||
|
||
def calculate_ambient_occlusion(world, size, x, y, z): | ||
directions = [ | ||
(-1, -1, 0), (1, -1, 0), (-1, 1, 0), (1, 1, 0), | ||
(-1, 0, -1), (1, 0, -1), (-1, 0, 1), (1, 0, 1), | ||
(0, -1, -1), (0, 1, -1), (0, -1, 1), (0, 1, 1), | ||
(-1, 0, 0), (1, 0, 0), (0, -1, 0), (0, 1, 0), (0, 0, -1), (0, 0, 1) | ||
] | ||
occlusion = 0 | ||
for dx, dy, dz in directions: | ||
nx, ny, nz = x + dx, y + dy, z + dz | ||
if 0 <= nx < size and 0 <= ny < size and 0 <= nz < size: | ||
if world[nx, ny, nz] > 0: | ||
occlusion += 1 | ||
return occlusion / len(directions) | ||
|
||
def precompute_ambient_occlusion(world, size): | ||
ao_matrix = np.zeros_like(world, dtype=np.float32) | ||
for x in range(size): | ||
for y in range(size): | ||
for z in range(size): | ||
if world[x, y, z] > 0: | ||
ao_matrix[x, y, z] = calculate_ambient_occlusion(world, size, x, y, z) | ||
return ao_matrix | ||
|
||
class NoiseGenerator: | ||
def __init__(self, noise_type='perlin', scale=10.0, octaves=4, persistence=0.5, lacunarity=2.0): | ||
self.noise_type = noise_type | ||
self.scale = scale | ||
self.octaves = octaves | ||
self.persistence = persistence | ||
self.lacunarity = lacunarity | ||
|
||
def generate_noise(self, x, y, z): | ||
if self.noise_type == 'perlin': | ||
return pnoise3(x / self.scale, y / self.scale, z / self.scale, octaves=self.octaves, persistence=self.persistence, lacunarity=self.lacunarity) | ||
elif self.noise_type == 'simplex': | ||
return snoise3(x / self.scale, y / self.scale, z / self.scale, octaves=self.octaves, persistence=self.persistence, lacunarity=self.lacunarity) | ||
else: | ||
raise ValueError("Unsupported noise type") | ||
|
||
class VoxelWorld: | ||
themes = { | ||
'Moon': {'voxel_color': (150, 100, 150), 'light_intensity': 0.7, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Gray': {'voxel_color': (130, 130, 130), 'light_intensity': 0.8, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Rose': {'voxel_color': (180, 100, 100), 'light_intensity': 0.6, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Lilac': {'voxel_color': (160, 160, 200), 'light_intensity': 0.9, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Snow': {'voxel_color': (200, 200, 250), 'light_intensity': 0.5, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Mint': {'voxel_color': (180, 255, 200), 'light_intensity': 0.7, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Peach': {'voxel_color': (255, 204, 170), 'light_intensity': 0.8, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Sky': {'voxel_color': (135, 206, 235), 'light_intensity': 0.9, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Lavender': {'voxel_color': (230, 230, 250), 'light_intensity': 0.6, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Lemon': {'voxel_color': (255, 255, 204), 'light_intensity': 0.5, 'fog_intensity': 0.1, 'light_source_position': (64, 64, 128)}, | ||
'Ice': {'voxel_color': (200, 255, 255), 'light_intensity': 0.9, 'fog_intensity': 0.05, 'light_source_position': (64, 64, 128)}, | ||
'Obsidian': {'voxel_color': (50, 50, 50), 'light_intensity': 0.9, 'fog_intensity': 0.05, 'light_source_position': (64, 64, 128)}, | ||
'Mercury': {'voxel_color': (230, 230, 230), 'light_intensity': 0.9, 'fog_intensity': 0.05, 'light_source_position': (64, 64, 128)}, | ||
} | ||
|
||
def __init__(self, voxel_matrix, theme_name='Lilac', resolution=2, zoom=1.0, show_light_source=False, time_render=False, dark_bg=False, color_matrix=None, transparency_matrix=None, specularity_matrix=None): | ||
self.size = voxel_matrix.shape[0] | ||
self.world = voxel_matrix | ||
|
||
theme = VoxelWorld.themes[theme_name] | ||
self.voxel_color = theme['voxel_color'] | ||
self.light_intensity = theme['light_intensity'] | ||
self.fog_intensity = theme['fog_intensity'] | ||
self.light_source_position = theme['light_source_position'] | ||
|
||
self.resolution = resolution | ||
self.zoom = zoom | ||
self.show_light_source = show_light_source | ||
self.time_render = time_render | ||
self.dark_bg = dark_bg | ||
self.ao_matrix = precompute_ambient_occlusion(voxel_matrix, self.size) | ||
self.color_matrix = color_matrix if color_matrix is not None else np.zeros((self.size, self.size, self.size, 3), dtype=np.uint8) | ||
self.transparency_matrix = transparency_matrix if transparency_matrix is not None else np.ones((self.size, self.size, self.size), dtype=np.float32) | ||
self.specularity_matrix = specularity_matrix if specularity_matrix is not None else np.zeros((self.size, self.size, self.size), dtype=np.float32) | ||
|
||
def update(self, voxel_matrix=None, color_matrix=None, transparency_matrix=None, specularity_matrix=None): | ||
if voxel_matrix is not None: | ||
self.world = voxel_matrix | ||
self.ao_matrix = precompute_ambient_occlusion(voxel_matrix, self.size) | ||
if color_matrix is not None: | ||
self.color_matrix = color_matrix | ||
if transparency_matrix is not None: | ||
self.transparency_matrix = transparency_matrix | ||
if specularity_matrix is not None: | ||
self.specularity_matrix = specularity_matrix | ||
|
||
def render(self, viewing_angle=(45, 30)): | ||
if self.time_render: | ||
start_time = time.time() | ||
|
||
angle_x, angle_y = viewing_angle | ||
angle_x_rad = np.radians(angle_x) | ||
angle_y_rad = np.radians(angle_y) | ||
|
||
img_size = int(self.size * self.resolution * self.zoom * 2) | ||
margin = int(self.size * self.resolution * self.zoom) | ||
bg_color = (50, 50, 50, 255) if self.dark_bg else (220, 220, 220, 255) | ||
image = Image.new('RGBA', (img_size + margin, img_size + margin), bg_color) | ||
draw = ImageDraw.Draw(image) | ||
|
||
voxel_center_x = self.size // 2 | ||
voxel_center_y = self.size // 2 | ||
voxel_center_z = self.size // 2 | ||
center_x = (voxel_center_x - voxel_center_y) * np.cos(angle_x_rad) * self.resolution * self.zoom | ||
center_y = (voxel_center_x + voxel_center_y) * np.sin(angle_x_rad) * self.resolution * self.zoom - voxel_center_z * np.tan(angle_y_rad) * self.resolution * self.zoom | ||
offset_x = (img_size + margin) // 2 - int(center_x) | ||
offset_y = (img_size + margin) // 2 - int(center_y) | ||
|
||
for x in range(self.size): | ||
for y in range(self.size): | ||
self.render_col(draw, x, y, angle_x_rad, angle_y_rad, offset_x, offset_y) | ||
|
||
if self.fog_intensity > 0: | ||
image = self.apply_fog(image) | ||
|
||
if self.show_light_source: | ||
image = self.add_light_source_sphere(image, offset_x, offset_y) | ||
|
||
bbox = image.getbbox() | ||
if bbox: | ||
image = image.crop(bbox) | ||
|
||
if self.time_render: | ||
elapsed_time = time.time() - start_time | ||
print(f"Rendering time: {elapsed_time:.2f} seconds") | ||
|
||
return image | ||
|
||
def render_col(self, draw, x, y, angle_x_rad, angle_y_rad, offset_x, offset_y): | ||
for z in range(self.size): | ||
if self.world[x, y, z] > 0: | ||
ao = self.ao_matrix[x, y, z] | ||
brightness = int((1.0 - ao) * 255 * self.light_intensity) | ||
base_color = tuple(min(255, int(c * brightness / 255)) for c in self.voxel_color) | ||
voxel_color = tuple(self.color_matrix[x, y, z]) if np.any(self.color_matrix[x, y, z]) else base_color | ||
transparency = self.transparency_matrix[x, y, z] | ||
specularity = self.specularity_matrix[x, y, z] | ||
self.draw_voxel(draw, x, y, z, voxel_color, transparency, specularity, angle_x_rad, angle_y_rad, offset_x, offset_y) | ||
|
||
def draw_voxel(self, draw, x, y, z, color, transparency, specularity, angle_x_rad, angle_y_rad, offset_x, offset_y): | ||
ox = int((x - y) * np.cos(angle_x_rad) * self.resolution * self.zoom + offset_x) | ||
oy = int((x + y) * np.sin(angle_x_rad) * self.resolution * self.zoom - z * np.tan(angle_y_rad) * self.resolution * self.zoom + offset_y) | ||
|
||
size = int(self.resolution * self.zoom) | ||
color_with_transparency = tuple(int(c * transparency) for c in color) + (int(255 * transparency),) | ||
|
||
draw.polygon([ | ||
(ox, oy), | ||
(ox + size, oy + size // 2), | ||
(ox, oy + size), | ||
(ox - size, oy + size // 2), | ||
], fill=color_with_transparency) | ||
|
||
draw.polygon([ | ||
(ox, oy + size), | ||
(ox - size, oy + size // 2), | ||
(ox - size, oy + size + size // 2), | ||
(ox, oy + size + size // 2), | ||
], fill=tuple(int(c // 2 * transparency) for c in color) + (int(255 * transparency),)) | ||
|
||
draw.polygon([ | ||
(ox, oy + size), | ||
(ox + size, oy + size // 2), | ||
(ox + size, oy + size + size // 2), | ||
(ox, oy + size + size // 2), | ||
], fill=tuple(int(c // 3 * transparency) for c in color) + (int(255 * transparency),)) | ||
|
||
def apply_fog(self, image): | ||
fog_overlay = Image.new('RGBA', image.size, (220, 220, 220, int(255 * self.fog_intensity * 0.5))) | ||
return Image.alpha_composite(image, fog_overlay) | ||
|
||
def add_light_source_sphere(self, image, offset_x, offset_y): | ||
sphere_radius = 8 | ||
light_color = (255, 165, 0, 255) # Bright arid orange | ||
light_position_x = int(self.light_source_position[0]) | ||
light_position_y = int(self.light_source_position[1]) | ||
draw = ImageDraw.Draw(image) | ||
draw.ellipse([light_position_x + offset_x - sphere_radius, light_position_y + offset_y - sphere_radius, light_position_x + offset_x + sphere_radius, light_position_y + offset_y + sphere_radius], fill=light_color) | ||
return image | ||
|
||
@staticmethod | ||
def show_themes(): | ||
size = 8 | ||
|
||
def generate_perlin_voxel_world(size): | ||
voxel_matrix = np.zeros((size, size, size), dtype=np.uint8) | ||
noise_gen = NoiseGenerator(scale=5.0) | ||
for x in range(size): | ||
for y in range(size): | ||
for z in range(size): | ||
if noise_gen.generate_noise(x, y, z) > 0: | ||
voxel_matrix[x, y, z] = 1 | ||
return voxel_matrix | ||
|
||
viewing_angle = (45, 30) | ||
images = [] | ||
|
||
for theme_name in VoxelWorld.themes.keys(): | ||
voxel_matrix = generate_perlin_voxel_world(size) | ||
world = VoxelWorld(voxel_matrix, theme_name, resolution=10, zoom=1.5, dark_bg=False) | ||
image = world.render(viewing_angle) | ||
images.append(image) | ||
|
||
# Create a 4x4 grid of subplots | ||
fig, axs = plt.subplots(4, 4, figsize=(15, 15)) | ||
|
||
# Flatten the 2D array of subplots into a 1D array | ||
axs = axs.flatten() | ||
|
||
for ax, img, theme_name in zip(axs, images, VoxelWorld.themes.keys()): | ||
ax.imshow(img) | ||
ax.axis('off') | ||
ax.set_title(theme_name) | ||
|
||
plt.show() | ||
|
||
plt.show() | ||
|
||
class Animations: | ||
@staticmethod | ||
def create_voxel_img(voxel_matrix, theme_name, resolution=2, viewing_angle=(45, 30), zoom=1.0, show_light_source=False, time_render=False, dark_bg=False, color_matrix=None, transparency_matrix=None, specularity_matrix=None): | ||
world = VoxelWorld(voxel_matrix, theme_name, resolution, zoom, show_light_source, time_render, dark_bg, color_matrix, transparency_matrix, specularity_matrix) | ||
image = world.render(viewing_angle) | ||
image = image.convert('RGB') | ||
|
||
byte_stream = io.BytesIO() | ||
image.save(byte_stream, format='PNG') | ||
byte_stream.seek(0) | ||
return byte_stream | ||
|
||
@staticmethod | ||
def gen_gif(stack, theme_name, resolution=2, viewing_angle=(45, 30), zoom=1.0, show_light_source=False, dark_bg=False, color_matrix_stack=None, transparency_matrix_stack=None, specularity_matrix_stack=None): | ||
images = [] | ||
for i, voxel_matrix in enumerate(stack): | ||
color_matrix = color_matrix_stack[i] if color_matrix_stack is not None else None | ||
transparency_matrix = transparency_matrix_stack[i] if transparency_matrix_stack is not None else None | ||
specularity_matrix = specularity_matrix_stack[i] if specularity_matrix_stack is not None else None | ||
byte_stream = VoxelWorld.Animations.create_voxel_img(voxel_matrix, theme_name, resolution, viewing_angle, zoom, show_plane, show_light_source, False, dark_bg, color_matrix, transparency_matrix, specularity_matrix) | ||
image = Image.open(byte_stream) | ||
images.append(image) | ||
|
||
gif_byte_stream = io.BytesIO() | ||
images[0].save(gif_byte_stream, format='GIF', save_all=True, append_images=images[1:], loop=0, duration=100) | ||
gif_byte_stream.seek(0) | ||
return gif_byte_stream | ||
|
||
@staticmethod | ||
def rotate_voxel_matrix(voxel_matrix, angle): | ||
size = voxel_matrix.shape[0] | ||
rotated_matrix = np.zeros_like(voxel_matrix) | ||
angle_rad = np.radians(angle) | ||
cos_angle = np.cos(angle_rad) | ||
sin_angle = np.sin(angle_rad) | ||
|
||
for x in range(size): | ||
for y in range(size): | ||
for z in range(size): | ||
if (voxel_matrix[x, y, z] > 0): | ||
new_x = int(x * cos_angle - z * sin_angle) | ||
new_z = int(x * sin_angle + z * cos_angle) | ||
if 0 <= new_x < size and 0 <= new_z < size: | ||
rotated_matrix[new_x, y, new_z] = 1 | ||
return rotated_matrix | ||
|
||
def display_gif(gif_byte_stream): | ||
display(IPImage(data=gif_byte_stream.getvalue())) |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,3 +17,6 @@ dependencies = [ | |
"pillow", | ||
"matplotlib" | ||
] | ||
|
||
[tool.distutils.bdist_wheel] | ||
universal = true |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
Metadata-Version: 2.1 | ||
Name: voxel_world | ||
Version: 0.0.1 | ||
Summary: Delicious Voxel worlds in Python | ||
Author-email: JP <[email protected]> | ||
Requires-Python: >=3.7 | ||
Description-Content-Type: text/markdown | ||
License-File: LICENSE | ||
Requires-Dist: noise | ||
Requires-Dist: numpy | ||
Requires-Dist: pillow | ||
Requires-Dist: matplotlib | ||
|
||
# VoxelWorld | ||
Create delicious Voxel worlds in Python | ||
|
||
## Install | ||
|
||
```sh | ||
git clone https://github.com/jackparmer/VoxelWorld.git | ||
cd VoxelWorld | ||
python3 -m pip install . | ||
|
||
from voxel_world import VoxelWorld | ||
``` | ||
|
||
## About | ||
|
||
For physics simulation, games, art, and fun | ||
|
||
Inspo: https://github.com/wwwtyro/vixel | ||
|
||
Features! | ||
- Automatic GIF generation | ||
- Numpy 3d ones array in -> Voxel world out | ||
- Fast-ish (as fast as rendering on the CPU can be) | ||
- Portable! Outputs simple image files | ||
- Notebooks! Works well in the Jupyter notebook ecosystem | ||
- Eye candy! [Ambient occlusion](https://en.wikipedia.org/wiki/Ambient_occlusion), specularity, etc | ||
|
||
Known issues (TODO) | ||
- Speed: Need to migrate to a GPU-based renderer while maintaining portability (suggestions?) | ||
- Illumination: Light source ray tracing is wonky - but you can fake it (see light_source.py example) | ||
- Cut offs: The bottom of some voxel cubes are cut off - I'm not sure why | ||
- Likely much more... | ||
|
||
*** | ||
|
||
# Examples | ||
|
||
## Randomly generated worlds | ||
|
||
```py | ||
import random | ||
import numpy as np | ||
from noise import pnoise3 | ||
from voxel_world import VoxelWorld | ||
from IPython.display import display, Image as IPImage # Jupyter notebook | ||
|
||
display(IPImage(data=VoxelWorld.Animations.create_voxel_img( | ||
np.array([[[1 if pnoise3(x / 10.0, y / 10.0, z / 10.0) > random.uniform(-0.2, 0.2) else 0 for z in range(16)] for y in range(16)] for x in range(16)], dtype=np.uint8), | ||
random.choice(list(VoxelWorld.themes.keys())), | ||
resolution=10, | ||
viewing_angle=(random.randint(0, 90), random.randint(0, 90)), | ||
zoom=2.0, | ||
show_light_source=False, | ||
dark_bg=False | ||
).getvalue())) | ||
``` | ||
|
||
![image](https://github.com/jackparmer/VoxelWorld/assets/1865834/25bd612e-b8e9-42ed-91b4-014921173900) | ||
|
||
![image](https://github.com/jackparmer/VoxelWorld/assets/1865834/11d299d1-532a-4ef4-a5a0-6a7bb93c1126) | ||
|
||
![image](https://github.com/jackparmer/VoxelWorld/assets/1865834/9085eab6-4091-4548-8c61-5fe875a19cc2) | ||
|
||
![image](https://github.com/jackparmer/VoxelWorld/assets/1865834/cc435d8b-e5c0-4bab-88b3-f66de29a48a3) | ||
|
||
## [examples/sandworld.py](examples/sand_world.py) | ||
|
||
![image](https://github.com/jackparmer/VoxelWorld/assets/1865834/f2a61fae-5133-4e2c-8bf9-71e69c1d0948) | ||
|
||
## [examples/light_source.py](examples/light_source.py) | ||
|
||
![download (1)](https://github.com/jackparmer/VoxelWorld/assets/1865834/d86f3e6a-322a-4273-8260-fc41fb215eaf) | ||
|
||
## [examples/jill_of_the_jungle.py](examples/jill_of_the_jungle.py) | ||
|
||
![jill_of_the_jungle](https://github.com/jackparmer/VoxelWorld/assets/1865834/820494a5-452f-4f87-b6c7-bbe4abc3e65e) | ||
|
||
## [examples/earth_tones.py](examples/earth_tones.py) | ||
|
||
![earth_tones](https://github.com/jackparmer/VoxelWorld/assets/1865834/1cffc6bf-a07c-4804-86fa-783dae51b3b6) | ||
|
||
## Mono-color themes | ||
|
||
```py | ||
from voxel_world import VoxelWorld | ||
|
||
world = VoxelWorld.show_themes() # Jupyter notebook only | ||
``` | ||
![image](https://github.com/jackparmer/VoxelWorld/assets/1865834/ab7eca82-5b20-4b7e-bbae-a2e8350b4611) |
Oops, something went wrong.