DEV Community

Cover image for Blender On Acid
DJRaptor
DJRaptor

Posted on

Blender On Acid

BlenderOnAcid release v4 is coming soon, and will include the full source code of MiniMario for educational purposes. In this post i will cover how most of this demo works, you will learn about: TPython, JavaScript, ODE, bpy, and inter-process communication.

Alt Text

Blender has its own builtin text editor, and the .blend file format can hold text objects, which can be referenced by other objects. Above is all of the demo source code, along with the generated 3D data. For learning how something works, it helps to have it all in a single .blend file.

Personally I do not like writing code in Blender's text editor, and instead prefer a fully data-driven style, where I generate the single .blend file from another Python script, this generator script sets up the scene, creates the text objects, and links them all together.

import bpy, os, sys, math

thispath = os.path.split(__file__)[0]

world = bpy.data.worlds[0]
world['threejs_canvas'] = None
script = bpy.data.texts.new(name='minimario_main')
world.threejs_script = script

src = open(os.path.join(thispath,'minimario/minimario_escapes_main.py'),'rb').read().decode('utf-8')
script.from_string( src )

js = bpy.data.texts.new(name='minimario_escapes_js')
src = open(os.path.join(thispath,'minimario/minimario_escapes_js.py'),'rb').read().decode('utf-8')
js.from_string( src )
script.include0 = js

shared = bpy.data.texts.new(name='minimario_shared')
src = open(os.path.join(thispath,'minimario/minimario_shared.py'),'rb').read().decode('utf-8')
shared.from_string( src )
script.include1 = shared

draw = bpy.data.texts.new(name='minimario_draw')
src = open(os.path.join(thispath,'minimario/minimario_draw.py'),'rb').read().decode('utf-8')
draw.from_string( src )
script.include2 = draw


blender_start = bpy.data.texts.new(name='minimario_startup')
world.blender_startup_script = blender_start
blender_start.include0 = shared

src = open(os.path.join(thispath,'minimario/minimario_startup.py'),'rb').read().decode('utf-8')
blender_start.from_string( src )

onupdate = '''
import math

M = bpy.data.objects['mario_collision']
MR = bpy.data.objects['marioroot']

if M.location[0] + 0.1 < M['px']:
    if MR.rotation_euler.z < math.radians( 180 ):
        MR.rotation_euler.z += 0.5
else:
    MR.rotation_euler.z *= 0.75

M['px'] = M.location[0]

if M.location.x > 9 and M.location.z > 8 and M.location.z < 13:
    bname = 'brick-%s' % int(M.location.x)
    if bname not in bpy.data.objects:
        bname = 'brick-%s' % int(M.location.x+0.5)
    if bname not in bpy.data.objects:
        bname = 'brick-%s' % int(M.location.x-0.5)
    if bname in bpy.data.objects:
        brick = bpy.data.objects[ bname ]
        if 'broken' not in brick.keys():
            print('breaking new brick')
            brick['broken'] = True
            bpy.ops.ode.cell_fracture(object_name=bname)
'''

blender_onupdate = bpy.data.texts.new(name='minimario_onupdate')
world.blender_script = blender_onupdate
blender_onupdate.from_string(onupdate)


Above you can see that basically this script just reads other python scripts from a sub-folder and creates the text objects. Some code is inline, the onupdate script, this gets called every frame in Blender.

Alt Text

Above Mario starts out in the Emscripten|SDL window, which he jumps out of into the Emscripten debugger, changing from SDL drawn pixels into CSS ASCII Art, when he leaves the web browser window and enters Blender he transforms into 3D ASCII Art. He then breaks some blocks using ODE physics and my customized cell fracture script. Finally he returns to the browser window and knocks the SDL window from below and jumps back into the SDL window.

The CSS Mario is simply drawn underneath the SDL window, his position is synchronized from TPython with this function call.

set_ascii_mario( B.getPosition()[0]-60, -float(B.getPosition()[1]+320), state["direction"] )

The variable B is the ODE Body used to simulate his physics. The definition of set_ascii_mario is below.

def set_ascii_mario(x,y, direction):
    bump *= 0.75
    canvas.style.top = (75 - bump) + 'px'
    if direction == 1:
        marioR.hidden = true
        mariodiv.hidden = false
        mariodiv.style.left = (x + canvas.offsetLeft) + 'px'
        mariodiv.style.top  = y + 'px'
    else:
        mariodiv.hidden = true
        marioR.hidden = false
        marioR.style.left = (x + canvas.offsetLeft) + 'px'
        marioR.style.top  = y + 'px'

Above you were probably expecting to see JavaScript instead of Python. While pure JavaScript is allowed to be mixed with your python script running in the TPython WASM compiled interpreter, it is preferred that you write your JS code in a Python-style, and let the TPython toolchain transpile it to JS, this is because then the JS function will be automatically exposed to your TPython script for easy calling.

At this point you might be wondering how is the position of Mario synchronized with Blender. This is done using the standard XMLHttpRequest.

def sendmario():
    var msg = [{"name":"mario_collision", "pos":[X,-Z,Y]}]
    var req = new XMLHttpRequest()
    req.addEventListener("load", sendmario)
    req.open("GET", "http://localhost:8080/update?" + JSON.stringify(msg))
    req.send()
setTimeout(sendmario, 100)

Above hijacks the builtin server of BlenderOnAcid normally used to synchronize ThreeJS objects, but in this case we are not using ThreeJS. Instead we have created a mario_collision object ahead of time in the blender startup script, which is the parent of the 3D ASCII Mario. Below is the blender startup script.

import bpy, math, _bpy

ob = bpy.data.objects['Cube']
ob.name = 'mario_collision'
ob.display_type = "WIRE"
ob.ode_size[0] = 2
ob.ode_size[1] = 2
ob.ode_size[2] = 2
ob.ode_kinematic = True
_bpy.ode_add_object( ob )
ob['px'] = 0.0

root = bpy.data.objects.new(name='marioroot', object_data=None)
root.scale *= 0.12
bpy.context.collection.objects.link(root)
root.parent = ob


cam = bpy.data.objects['Camera']
cam.location = [10, -30, 5]
cam.rotation_euler = [math.radians(90), 0, 0]


def make_mario( asciiart, collection, root ):
    z = 16
    for ln in asciiart:
        x = -16
        for c in ln:
            x += 2
            if c == '_':
                continue
            pix = bpy.data.objects.new(name='pixel', object_data=bpy.data.meshes['Cube'].copy())
            if c not in bpy.data.materials:
                mat = bpy.data.materials.new(name=c)
                mat.use_nodes = False
                if c == '?':
                    r,g,b = MarioPal['%']
                else:
                    r,g,b = MarioPal[c]
                mat.diffuse_color = [r/255,g/255,b/255,1.0]
            else:
                mat = bpy.data.materials[c]
            pix.data.materials[0]=mat
            collection.objects.link(pix)
            pix.location = [x,0,z]
            pix.scale.y = 2
            pix.modifiers.new(name='bevel', type="BEVEL")
            pix.parent=root

        z -= 2


col = bpy.data.collections.new(name='mariopixels')
bpy.context.collection.children.link(col)
make_mario( Mario, col, root )

def make_bricks():
    mat = bpy.data.materials.new(name='brick')
    mat.use_nodes = False
    mat.diffuse_color = [0.57, 0.15, 0.14, 1.0]
    x = 10
    z = 10
    for i in range(4):
        brick = bpy.data.objects.new(name='brick-%s' %int(x), object_data=bpy.data.meshes['Cube'].copy())
        bpy.context.collection.objects.link(brick)
        brick.location = [x, 0, z]
        brick.data.materials[0] = mat
        brick.ode_size[0] = 2
        brick.ode_size[1] = 2
        brick.ode_size[2] = 2
        brick.ode_kinematic = True
        _bpy.ode_add_object( brick )
        x += 2

make_bricks()

Above you can see I setup a secondary ODE physics simulation in Blender. The primary ODE physics simulation is actually running in the web browser sending position updates for mario_collision to this physics simulation here in Blender. Note that in Blender i am making these bodies ode_kinematic which means they only have collision, and respond to position updates from the UI or the builtin web server.

Finally, lets review the code which triggers the bricks to break, this happens in the main blender onupdate script.

M = bpy.data.objects['mario_collision']

if M.location.x > 9 and M.location.z > 8 and M.location.z < 13:
    bname = 'brick-%s' % int(M.location.x)
    if bname not in bpy.data.objects:
        bname = 'brick-%s' % int(M.location.x+0.5)
    if bname not in bpy.data.objects:
        bname = 'brick-%s' % int(M.location.x-0.5)
    if bname in bpy.data.objects:
        brick = bpy.data.objects[ bname ]
        if 'broken' not in brick.keys():
            print('breaking new brick')
            brick['broken'] = True
            bpy.ops.ode.cell_fracture(object_name=bname)

Why am I bypassing the ODE collision detection callback system, and writing ugly code like above to trigger the cell fracture? Because its simple, and to show you a simple trick for collision detection, where you round positions to integers and lookup an object by that rounded index. This cheat works well if your game-board objects are all about the same size of one.

Top comments (1)

Collapse
 
mirkan1 profile image
Mirkan

If what you made is a controllable mario or even is its just an animation, it is great.

I would love to take blender lessons from you