400 lines
14 KiB
Python
400 lines
14 KiB
Python
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
bl_info = {
|
|
"name": "Real Snow",
|
|
"description": "Generate snow mesh",
|
|
"author": "Marco Pavanello, Drew Perttula",
|
|
"version": (1, 3),
|
|
"blender": (3, 1, 0),
|
|
"location": "View 3D > Properties Panel",
|
|
"doc_url": "{BLENDER_MANUAL_URL}/addons/object/real_snow.html",
|
|
"tracker_url": "https://gitlab.com/marcopavanello/real-snow/-/issues",
|
|
"support": "COMMUNITY",
|
|
"category": "Object",
|
|
}
|
|
|
|
|
|
# Libraries
|
|
import math
|
|
import os
|
|
import random
|
|
import time
|
|
|
|
import bpy
|
|
import bmesh
|
|
from bpy.props import BoolProperty, FloatProperty, IntProperty, PointerProperty
|
|
from bpy.types import Operator, Panel, PropertyGroup
|
|
from mathutils import Vector
|
|
|
|
|
|
# Panel
|
|
class REAL_PT_snow(Panel):
|
|
bl_space_type = "VIEW_3D"
|
|
bl_context = "objectmode"
|
|
bl_region_type = "UI"
|
|
bl_label = "Snow"
|
|
bl_category = "Real Snow"
|
|
|
|
def draw(self, context):
|
|
scn = context.scene
|
|
settings = scn.snow
|
|
layout = self.layout
|
|
|
|
col = layout.column(align=True)
|
|
col.prop(settings, 'coverage', slider=True)
|
|
col.prop(settings, 'height')
|
|
|
|
layout.use_property_split = True
|
|
layout.use_property_decorate = False
|
|
flow = layout.grid_flow(row_major=True, columns=0, even_columns=False, even_rows=False, align=True)
|
|
col = flow.column()
|
|
col.prop(settings, 'vertices')
|
|
|
|
row = layout.row(align=True)
|
|
row.scale_y = 1.5
|
|
row.operator("snow.create", text="Add Snow", icon="FREEZE")
|
|
|
|
|
|
class SNOW_OT_Create(Operator):
|
|
bl_idname = "snow.create"
|
|
bl_label = "Create Snow"
|
|
bl_description = "Create snow"
|
|
bl_options = {'REGISTER', 'UNDO'}
|
|
|
|
@classmethod
|
|
def poll(cls, context) -> bool:
|
|
return bool(context.selected_objects)
|
|
|
|
def execute(self, context):
|
|
coverage = context.scene.snow.coverage
|
|
height = context.scene.snow.height
|
|
vertices = context.scene.snow.vertices
|
|
|
|
# Get a list of selected objects, except non-mesh objects
|
|
input_objects = [obj for obj in context.selected_objects if obj.type == 'MESH']
|
|
snow_list = []
|
|
# Start UI progress bar
|
|
length = len(input_objects)
|
|
context.window_manager.progress_begin(0, 10)
|
|
timer = 0
|
|
for obj in input_objects:
|
|
# Timer
|
|
context.window_manager.progress_update(timer)
|
|
# Duplicate mesh
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
obj.select_set(True)
|
|
context.view_layer.objects.active = obj
|
|
object_eval = obj.evaluated_get(context.view_layer.depsgraph)
|
|
mesh_eval = bpy.data.meshes.new_from_object(object_eval)
|
|
snow_object = bpy.data.objects.new("Snow", mesh_eval)
|
|
snow_object.matrix_world = obj.matrix_world
|
|
context.collection.objects.link(snow_object)
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
context.view_layer.objects.active = snow_object
|
|
snow_object.select_set(True)
|
|
bpy.ops.object.mode_set(mode = 'EDIT')
|
|
bm_orig = bmesh.from_edit_mesh(snow_object.data)
|
|
bm_copy = bm_orig.copy()
|
|
bm_copy.transform(obj.matrix_world)
|
|
bm_copy.normal_update()
|
|
# Get faces data
|
|
delete_faces(vertices, bm_copy, snow_object)
|
|
ballobj = add_metaballs(context, height, snow_object)
|
|
context.view_layer.objects.active = snow_object
|
|
surface_area = area(snow_object)
|
|
snow = add_particles(context, surface_area, height, coverage, snow_object, ballobj)
|
|
add_modifiers(snow)
|
|
# Place inside collection
|
|
context.view_layer.active_layer_collection = context.view_layer.layer_collection
|
|
if "Snow" not in context.scene.collection.children:
|
|
coll = bpy.data.collections.new("Snow")
|
|
context.scene.collection.children.link(coll)
|
|
else:
|
|
coll = bpy.data.collections["Snow"]
|
|
coll.objects.link(snow)
|
|
context.view_layer.layer_collection.collection.objects.unlink(snow)
|
|
add_material(snow)
|
|
# Parent with object
|
|
snow.parent = obj
|
|
snow.matrix_parent_inverse = obj.matrix_world.inverted()
|
|
# Add snow to list
|
|
snow_list.append(snow)
|
|
# Update progress bar
|
|
timer += 0.1 / length
|
|
# Select created snow meshes
|
|
for s in snow_list:
|
|
s.select_set(True)
|
|
# End progress bar
|
|
context.window_manager.progress_end()
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
def add_modifiers(snow):
|
|
bpy.ops.object.transform_apply(location=False, scale=True, rotation=False)
|
|
# Decimate the mesh to get rid of some visual artifacts
|
|
snow.modifiers.new("Decimate", 'DECIMATE')
|
|
snow.modifiers["Decimate"].ratio = 0.5
|
|
snow.modifiers.new("Subdiv", "SUBSURF")
|
|
snow.modifiers["Subdiv"].render_levels = 1
|
|
snow.modifiers["Subdiv"].quality = 1
|
|
snow.cycles.use_adaptive_subdivision = True
|
|
|
|
|
|
def add_particles(context, surface_area: float, height: float, coverage: float, snow_object: bpy.types.Object, ballobj: bpy.types.Object):
|
|
# Approximate the number of particles to be emitted
|
|
number = int(surface_area * 50 * (height ** -2) * ((coverage / 100) ** 2))
|
|
bpy.ops.object.particle_system_add()
|
|
particles = snow_object.particle_systems[0]
|
|
psettings = particles.settings
|
|
psettings.type = 'HAIR'
|
|
psettings.render_type = 'OBJECT'
|
|
# Generate random number for seed
|
|
random_seed = random.randint(0, 1000)
|
|
particles.seed = random_seed
|
|
# Set particles object
|
|
psettings.particle_size = height
|
|
psettings.instance_object = ballobj
|
|
psettings.count = number
|
|
# Convert particles to mesh
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
context.view_layer.objects.active = ballobj
|
|
ballobj.select_set(True)
|
|
bpy.ops.object.convert(target='MESH')
|
|
snow = bpy.context.active_object
|
|
snow.scale = [0.09, 0.09, 0.09]
|
|
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY')
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
snow_object.select_set(True)
|
|
bpy.ops.object.delete()
|
|
snow.select_set(True)
|
|
return snow
|
|
|
|
|
|
def add_metaballs(context, height: float, snow_object: bpy.types.Object) -> bpy.types.Object:
|
|
ball_name = "SnowBall"
|
|
ball = bpy.data.metaballs.new(ball_name)
|
|
ballobj = bpy.data.objects.new(ball_name, ball)
|
|
bpy.context.scene.collection.objects.link(ballobj)
|
|
# These settings have proven to work on a large amount of scenarios
|
|
ball.resolution = 0.7 * height + 0.3
|
|
ball.threshold = 1.3
|
|
element = ball.elements.new()
|
|
element.radius = 1.5
|
|
element.stiffness = 0.75
|
|
ballobj.scale = [0.09, 0.09, 0.09]
|
|
return ballobj
|
|
|
|
|
|
def delete_faces(vertices, bm_copy, snow_object: bpy.types.Object):
|
|
# Find upper faces
|
|
if vertices:
|
|
selected_faces = set(face.index for face in bm_copy.faces if face.select)
|
|
# Based on a certain angle, find all faces not pointing up
|
|
down_faces = set(face.index for face in bm_copy.faces if Vector((0, 0, -1.0)).angle(face.normal, 4.0) < (math.pi / 2.0 + 0.5))
|
|
bm_copy.free()
|
|
bpy.ops.mesh.select_all(action='DESELECT')
|
|
# Select upper faces
|
|
mesh = bmesh.from_edit_mesh(snow_object.data)
|
|
for face in mesh.faces:
|
|
if vertices:
|
|
if face.index not in selected_faces:
|
|
face.select = True
|
|
if face.index in down_faces:
|
|
face.select = True
|
|
# Delete unnecessary faces
|
|
faces_select = [face for face in mesh.faces if face.select]
|
|
bmesh.ops.delete(mesh, geom=faces_select, context='FACES_KEEP_BOUNDARY')
|
|
mesh.free()
|
|
bpy.ops.object.mode_set(mode = 'OBJECT')
|
|
|
|
|
|
def area(obj: bpy.types.Object) -> float:
|
|
bm_obj = bmesh.new()
|
|
bm_obj.from_mesh(obj.data)
|
|
bm_obj.transform(obj.matrix_world)
|
|
area = sum(face.calc_area() for face in bm_obj.faces)
|
|
bm_obj.free
|
|
return area
|
|
|
|
|
|
def add_material(obj: bpy.types.Object):
|
|
mat_name = "Snow"
|
|
# If material doesn't exist, create it
|
|
if mat_name in bpy.data.materials:
|
|
bpy.data.materials[mat_name].name = mat_name+".001"
|
|
mat = bpy.data.materials.new(mat_name)
|
|
mat.use_nodes = True
|
|
nodes = mat.node_tree.nodes
|
|
# Delete all nodes
|
|
for node in nodes:
|
|
nodes.remove(node)
|
|
# Add nodes
|
|
output = nodes.new('ShaderNodeOutputMaterial')
|
|
principled = nodes.new('ShaderNodeBsdfPrincipled')
|
|
vec_math = nodes.new('ShaderNodeVectorMath')
|
|
com_xyz = nodes.new('ShaderNodeCombineXYZ')
|
|
dis = nodes.new('ShaderNodeDisplacement')
|
|
mul1 = nodes.new('ShaderNodeMath')
|
|
add1 = nodes.new('ShaderNodeMath')
|
|
add2 = nodes.new('ShaderNodeMath')
|
|
mul2 = nodes.new('ShaderNodeMath')
|
|
mul3 = nodes.new('ShaderNodeMath')
|
|
range1 = nodes.new('ShaderNodeMapRange')
|
|
range2 = nodes.new('ShaderNodeMapRange')
|
|
range3 = nodes.new('ShaderNodeMapRange')
|
|
vor = nodes.new('ShaderNodeTexVoronoi')
|
|
noise1 = nodes.new('ShaderNodeTexNoise')
|
|
noise2 = nodes.new('ShaderNodeTexNoise')
|
|
noise3 = nodes.new('ShaderNodeTexNoise')
|
|
mapping = nodes.new('ShaderNodeMapping')
|
|
coord = nodes.new('ShaderNodeTexCoord')
|
|
# Change location
|
|
output.location = (100, 0)
|
|
principled.location = (-200, 600)
|
|
vec_math.location = (-400, 400)
|
|
com_xyz.location = (-600, 400)
|
|
dis.location = (-200, -100)
|
|
mul1.location = (-400, -100)
|
|
add1.location = (-600, -100)
|
|
add2.location = (-800, -100)
|
|
mul2.location = (-1000, -100)
|
|
mul3.location = (-1000, -300)
|
|
range1.location = (-400, 200)
|
|
range2.location = (-1200, -300)
|
|
range3.location = (-800, -300)
|
|
vor.location = (-1500, 200)
|
|
noise1.location = (-1500, 0)
|
|
noise2.location = (-1500, -250)
|
|
noise3.location = (-1500, -500)
|
|
mapping.location = (-1700, 0)
|
|
coord.location = (-1900, 0)
|
|
# Change node parameters
|
|
principled.distribution = "MULTI_GGX"
|
|
principled.subsurface_method = "RANDOM_WALK"
|
|
principled.inputs[0].default_value[0] = 0.904
|
|
principled.inputs[0].default_value[1] = 0.904
|
|
principled.inputs[0].default_value[2] = 0.904
|
|
principled.inputs[1].default_value = 1
|
|
principled.inputs[2].default_value[0] = 0.36
|
|
principled.inputs[2].default_value[1] = 0.46
|
|
principled.inputs[2].default_value[2] = 0.6
|
|
principled.inputs[3].default_value[0] = 0.904
|
|
principled.inputs[3].default_value[1] = 0.904
|
|
principled.inputs[3].default_value[2] = 0.904
|
|
principled.inputs[7].default_value = 0.224
|
|
principled.inputs[9].default_value = 0.1
|
|
principled.inputs[15].default_value = 0.1
|
|
vec_math.operation = "MULTIPLY"
|
|
vec_math.inputs[1].default_value[0] = 0.5
|
|
vec_math.inputs[1].default_value[1] = 0.5
|
|
vec_math.inputs[1].default_value[2] = 0.5
|
|
com_xyz.inputs[0].default_value = 0.36
|
|
com_xyz.inputs[1].default_value = 0.46
|
|
com_xyz.inputs[2].default_value = 0.6
|
|
dis.inputs[1].default_value = 0.1
|
|
dis.inputs[2].default_value = 0.3
|
|
mul1.operation = "MULTIPLY"
|
|
mul1.inputs[1].default_value = 0.1
|
|
mul2.operation = "MULTIPLY"
|
|
mul2.inputs[1].default_value = 0.6
|
|
mul3.operation = "MULTIPLY"
|
|
mul3.inputs[1].default_value = 0.4
|
|
range1.inputs[1].default_value = 0.525
|
|
range1.inputs[2].default_value = 0.58
|
|
range2.inputs[1].default_value = 0.069
|
|
range2.inputs[2].default_value = 0.757
|
|
range3.inputs[1].default_value = 0.069
|
|
range3.inputs[2].default_value = 0.757
|
|
vor.feature = "N_SPHERE_RADIUS"
|
|
vor.inputs[2].default_value = 30
|
|
noise1.inputs[2].default_value = 12
|
|
noise2.inputs[2].default_value = 2
|
|
noise2.inputs[3].default_value = 4
|
|
noise3.inputs[2].default_value = 1
|
|
noise3.inputs[3].default_value = 4
|
|
mapping.inputs[3].default_value[0] = 12
|
|
mapping.inputs[3].default_value[1] = 12
|
|
mapping.inputs[3].default_value[2] = 12
|
|
# Link nodes
|
|
link = mat.node_tree.links
|
|
link.new(principled.outputs[0], output.inputs[0])
|
|
link.new(vec_math.outputs[0], principled.inputs[2])
|
|
link.new(com_xyz.outputs[0], vec_math.inputs[0])
|
|
link.new(dis.outputs[0], output.inputs[2])
|
|
link.new(mul1.outputs[0], dis.inputs[0])
|
|
link.new(add1.outputs[0], mul1.inputs[0])
|
|
link.new(add2.outputs[0], add1.inputs[0])
|
|
link.new(mul2.outputs[0], add2.inputs[0])
|
|
link.new(mul3.outputs[0], add2.inputs[1])
|
|
link.new(range1.outputs[0], principled.inputs[14])
|
|
link.new(range2.outputs[0], mul3.inputs[0])
|
|
link.new(range3.outputs[0], add1.inputs[1])
|
|
link.new(vor.outputs[4], range1.inputs[0])
|
|
link.new(noise1.outputs[0], mul2.inputs[0])
|
|
link.new(noise2.outputs[0], range2.inputs[0])
|
|
link.new(noise3.outputs[0], range3.inputs[0])
|
|
link.new(mapping.outputs[0], vor.inputs[0])
|
|
link.new(mapping.outputs[0], noise1.inputs[0])
|
|
link.new(mapping.outputs[0], noise2.inputs[0])
|
|
link.new(mapping.outputs[0], noise3.inputs[0])
|
|
link.new(coord.outputs[3], mapping.inputs[0])
|
|
# Set displacement and add material
|
|
mat.cycles.displacement_method = "DISPLACEMENT"
|
|
obj.data.materials.append(mat)
|
|
|
|
|
|
# Properties
|
|
class SnowSettings(PropertyGroup):
|
|
coverage : IntProperty(
|
|
name = "Coverage",
|
|
description = "Percentage of the object to be covered with snow",
|
|
default = 100,
|
|
min = 0,
|
|
max = 100,
|
|
subtype = 'PERCENTAGE'
|
|
)
|
|
|
|
height : FloatProperty(
|
|
name = "Height",
|
|
description = "Height of the snow",
|
|
default = 0.3,
|
|
step = 1,
|
|
precision = 2,
|
|
min = 0.1,
|
|
max = 1
|
|
)
|
|
|
|
vertices : BoolProperty(
|
|
name = "Selected Faces",
|
|
description = "Add snow only on selected faces",
|
|
default = False
|
|
)
|
|
|
|
|
|
#############################################################################################
|
|
classes = (
|
|
REAL_PT_snow,
|
|
SNOW_OT_Create,
|
|
SnowSettings
|
|
)
|
|
|
|
register, unregister = bpy.utils.register_classes_factory(classes)
|
|
|
|
# Register
|
|
def register():
|
|
for cls in classes:
|
|
bpy.utils.register_class(cls)
|
|
bpy.types.Scene.snow = PointerProperty(type=SnowSettings)
|
|
|
|
|
|
# Unregister
|
|
def unregister():
|
|
for cls in classes:
|
|
bpy.utils.unregister_class(cls)
|
|
del bpy.types.Scene.snow
|
|
|
|
|
|
if __name__ == "__main__":
|
|
register()
|