Small showcase

Here are a few projects (professional and otherwise) that I think represent a little bit of my knowledge in lieu of a real portfolio, it has been curated with the role of an aspiring tech artist in mind. I have spent most of my career as a sort of technical level designer, and I think there is a decent amount of overlap with this role, and that my insights into working as a designer enhance my ability to work as the glue between disciplines.

(If any video looks like its stuck loading just press play anyway).


Tools

+

Maya rig batch exporter


This tool was requested by the animator at one of the companies I worked for, the issue that needed to be solved was that certain actions had to be constantly repeated each time a rig needed to be exported. As a lot of rigs constantly needed changes a batch exporting tool was requested which removed certain bones, the controls while keeping the users personal export settings intact.

The tool was made in Maya using Python and had export profiles saved in JSONs so the settings could easily be shared or changed depending on what rig the animator was working on.

house mesh
Tool interface



Usage

"Profile name":
The name for the specific export that is later shown in the dropdown to allow modifying the rules.

"Add objects to save":
Objects selected (and children) were added to a list, these were the objects to be exported for the specific profile. This was a additive option so any objects selected when clicking the button a second time were appended to the list.
"Add objects to delete":
Objects to be excluded from the list i.e. certain bones or the rig controls.
"Profile save location":
Where the fbx file should be exported to.
"Wipe new profile data":
To wipe the current "save" and "delete" data from the profile if one wishes to set it up again.
Next section dealt with the already saved profiles. "Profile":
The active profile to use the commands below at.
"Export profile objects":

  • Ran the one selected profile
  • Creating a undo history save point to later return to
  • Selecting objects
  • Deleting unwanted further objects
  • Saving user export settings
  • Setting fbx exporter to tool/company settings
  • Exporting file
  • Recovering user settings and restoring scene to undo history point


"Export all profile objects": Ran the above for all created profiles in the specific JSON.
"Select profile objects": Selected all meshes in specific profile excluding and unwanted objects, used to figure out issues with profile.
"Delete profile": Self explanatory.

Settings actions section existed to save or load different profiles to a JSON.

Code:


                                    import maya.cmds as cmds
                                    import maya.mel as mel
                                    import os
                                    import json
                                    import sys
                                    import time
                                    
                                    
                                    class MR_Window(object):
                                        
                                        def __init__(self):
                                            self.deleteChildren         = True # Option for False is not yet completed. Need additional time.
                                            self.debug                  = False
                                            self.wipeProfileAfterSave   = False
                                            self.window                 = "FBX batch exporter"
                                            self.title                  = "FBX batch exporter"
                                    
                                            self.settingsFile           = "/scripts/MellhageExporter/settings.json"
                                            self.settingsFile           = "F:/Windows_user_folders/Desktop/test.json"
                                            
                                            self.size                   = (200, 400)
                                            self.activeProfile          = False
                                            self.settings               = {'profiles': {}}
                                            self.saveObjs               = []
                                            self.delObjs                = []
                                            self.UndoInfo               = ""
                                            self.lastSaveLocation       = ""
                                            
                                            self.restartWindow()
                                            self.setupLayout()
                                            if self.debug == True:
                                                self.loadSettings()
                                                    
                                            
                                        def exportObjs(self, multiExport=False):
                                            if not multiExport:
                                                self.setActiveProfile()
                                            getFBXSettings()
                                            try:
                                                print ("settings: \n\t", self.settings)
                                                print ("activeProfile: \n\t", self.activeProfile)
                                                undoChunkOpen()
                                                print ("delete objs: \n\t", self.activeProfile['delObjs'])
                                                self.deleteObjs()
                                                print ("save objs: \n\t", self.activeProfile['objs'])
                                                self.selectObjs()
                                                exportFBX(self.activeProfile['path']) 
                                            except Exception as e:
                                                print (e)
                                                print ("PS. Run maya in admin mode.")
                                            finally:
                                                undoChunkOpen(False)
                                                setFBXSettings()
                                                pass
                                            cmds.undo()
                                            self.deselectObjs()
                                                
                                    
                                        def exportAllObjs(self, *args):
                                            originalProfile = self.activeProfile
                                            try:
                                                for profileEntry in self.settings['profiles'].keys():
                                                    print ("")
                                                    print (profileEntry)
                                                    self.activeProfile = self.settings['profiles'][profileEntry]
                                                    self.exportObjs(True)
                                            except Exception as e:
                                                print (e)
                                                pass
                                            self.activeProfile = originalProfile
                                    
                                    
                                        def setActiveProfile(self, *args):
                                            profileName = cmds.optionMenu(self.profileMenu, query = True, value = True)
                                            self.activeProfile = self.settings['profiles'][profileName]
                                    
                                    
                                        def selectObjs(self, *args):
                                            if self.activeProfile:
                                                mySel = cmds.ls(self.activeProfile['objs'])
                                                cmds.select(mySel, hi=True)
                                            else:
                                                print ("no active profile")
                                    
                                    
                                        def removeMenuItems(self, *args):
                                            profiles = cmds.optionMenu(self.profileMenu, query = True, ill = True)
                                            if profiles:
                                                for profile in profiles:
                                                    cmds.deleteUI(profile)
                                    
                                    
                                        def initializeMenuItems(self, *args):
                                            profiles = self.settings['profiles'].keys()
                                            for entry in profiles:
                                                cmds.menuItem(label=entry, parent=self.profileMenu)
                                                
                                    
                                        def updateProfileItems(self, *args):
                                            deletedItems = []
                                            newItems = []
                                            
                                            profiles = self.settings['profiles'].keys()
                                            
                                            menuItems = cmds.optionMenu(self.profileMenu, query=True, ils=True)
                                            menuItemNames = []
                                            
                                            for item in menuItems:
                                                itemName = cmds.menuItem(item, query=True, l=True)
                                                menuItemNames.append(itemName)
                                                
                                            print ("Profiles:")
                                            print (profiles)
                                            print ("MenuItemNames:")
                                            print (menuItemNames)
                                                    
                                            for item in profiles:
                                                if item in menuItemNames:
                                                    print ("found")
                                            
                                    
                                        def wipeState(self, *args):
                                            cmds.textFieldGrp(self.profileName, e=True, text="")
                                            self.saveObjs = []
                                            self.delObjs  = []
                                            
                                    
                                        def deleteProfile(self, *args):
                                            # If True passed into args[0] it skips prompt
                                            profileName = cmds.optionMenu(self.profileMenu, query = True, value = True)
                                            targetProfileIndex = cmds.optionMenu(self.profileMenu, query = True, sl = True)
                                            profiles    = cmds.optionMenu(self.profileMenu, query = True, ill = True)
                                            if profiles and targetProfileIndex:
                                                if args[0] == False:
                                                    result = cmds.confirmDialog( title='Confirm', message='Are you sure you wish to delete profile "'+profileName+'"?', button=['Yes','No'], defaultButton='Yes', cancelButton='No', dismissString='No')
                                                    if not result == "Yes":
                                                        return
                                                if profileName in self.settings['profiles']:
                                                    targetProfileIndex -= 1
                                                    cmds.deleteUI(profiles[targetProfileIndex])
                                                    del self.settings['profiles'][profileName]
                                                    print ("Profile data after delete:")
                                                    print (self.settings['profiles'])
                                    
                                        
                                        def deselectObjs(self, *args):
                                            cmds.select(cl=True)
                                    
                                    
                                        def loadSettings(self, *args):
                                    
                                    
                                            try:
                                                singleFilter = "JSON (*.json)"
                                                fPath = cmds.fileDialog2(fm=1, fileFilter=singleFilter)
                                                fEnding = fPath[0].split(".")[-1]
                                                if (fEnding == "json"):
                                                    self.settings = loadJson(fPath[0])
                                                    self.postLoad()
                                                    return True
                                                print ("Not a json file.")
                                                return False
                                            except Exception as e:
                                                print ("Failed to load settings file")
                                                print (e)
                                                return False
                                            
                                    
                                        def postLoad(self, *args):
                                            self.removeMenuItems()
                                            self.initializeMenuItems()
                                            self.setActiveProfile()
                                    
                                    
                                        def saveSettings(self, *args):
                                            singleFilter = "JSON (*.json)"
                                            fPath = cmds.fileDialog2(fm=0, fileFilter=singleFilter)[0]
                                            if fPath:
                                                saveJson(fPath, self.settings)
                                                   
                                    
                                        def saveProfile(self, *args):
                                            # Perform checks
                                            profile = cmds.textFieldGrp(self.profileName, query=True, text=True)
                                            if not self.saveProfileCheck(profile):
                                                return
                                    
                                            fPath = self.getSaveProfileSaveLocation(profile)
                                    
                                            if fPath:
                                                self.settings['profiles'][profile] = {}
                                                self.settings['profiles'][profile]['objs']    = self.saveObjs
                                                self.settings['profiles'][profile]['delObjs'] = self.delObjs
                                                self.settings['profiles'][profile]['path']    = fPath
                                                cmds.menuItem(label=profile, parent=self.profileMenu)
                                                self.setActiveProfile()
                                            
                                            if self.wipeProfileAfterSave:
                                                self.wipeState()
                                    
                                        def saveProfileCheck(self, profile):
                                            if not self.saveObjs:
                                                self.promptDialog("Warning", "No objects added to profile")
                                                return False
                                            if not profile:
                                                self.promptDialog("Warning", "No profile name")
                                                return False
                                    
                                            if profile in self.settings['profiles']:
                                                if not self.promptDialog("Overwrite", "Do you wish to overwrite the existing profile?", ["Yes", "No"], "Yes", "No", "No"):
                                                    return False
                                                self.deleteProfile(True)
                                                self.settings['profiles'].pop(profile, None)
                                            return True
                                    
                                    
                                    
                                        def getSaveProfileSaveLocation(self, profile):
                                            singleFilter = "FBX (*.fbx)"
                                            if self.lastSaveLocation:
                                                SaveLocList = self.lastSaveLocation.split("/")
                                                del SaveLocList[-1]
                                                SaveLoc = "/".join(SaveLocList)
                                                SaveLoc += "/" + profile
                                                print ("Expected preferred save location:")
                                                print (SaveLoc)
                                                fPath = cmds.fileDialog2(fm=0, fileFilter=singleFilter, startingDirectory=SaveLoc)[0]
                                            else:
                                                saveLoc = "./" + profile
                                                fPath = cmds.fileDialog2(fm=0, fileFilter=singleFilter, startingDirectory=saveLoc)[0]
                                            self.lastSaveLocation = fPath
                                            return fPath
                                        
                                    
                                        def setupLayout(self, *args):
                                            self.window = cmds.window(self.window, title=self.title, widthHeight=self.size)
                                            cmds.columnLayout(adjustableColumn = True)
                                            cmds.separator(height=10)
                                            
                                            cmds.text("Create profile")
                                            self.profileName    = cmds.textFieldGrp(label='profile name:', height=30)
                                            self.saveObjsBtn    = self.makeBtn("Add Objects to Save",   self.addSaveObjs)
                                            self.deleteObjsBtn  = self.makeBtn("Add Objects to Delete", self.addDeleteObjs)
                                            self.saveProfileBtn = self.makeBtn("Profile Save Location", self.saveProfile)
                                            self.wipeStateBtn   = self.makeBtn("Wipe New profile Data", self.wipeState)
                                            cmds.separator(height=10)
                                    
                                            cmds.text("Action on existing profile", height=20)
                                            self.profileMenu = cmds.optionMenu(label='profile: ', changeCommand=self.setActiveProfile)
                                            self.exportBtn        = self.makeBtn("Export profile objects",      self.exportObjs)
                                            self.exportAllBtn     = self.makeBtn("Export all profile objects", self.exportAllObjs)
                                            self.selectBtn        = self.makeBtn("Select profile objects",     self.selectObjs)
                                            self.deleteProfileBtn = self.makeBtn("Delete profile",             self.deleteProfile)
                                            cmds.separator(height=10)
                                            
                                            cmds.text("settings actions")
                                            self.loadSettingsBtn = self.makeBtn("Load settings", self.loadSettings)
                                            self.saveSettingsBtn = self.makeBtn("save settings", self.saveSettings)
                                            cmds.separator(height=10)
                                            
                                            if(self.debug):
                                                cmds.text("debug menu")
                                                self.testCommandBtn1 = self.makeBtn("testMenuItems", self.wipeState)
                                                self.testCommandBtn2 = self.makeBtn("addDeleteObjs", self.addDeleteObjs)
                                                self.testCommandBtn3 = self.makeBtn("addSaveObjs",   self.addSaveObjs)
                                                self.testCommandBtn4 = self.makeBtn("deleteObjs",    self.deleteObjs)
                                                self.testCommandBtn6 = self.makeBtn("WarningTest",   self.debugtestWarning)         
                                                self.testCommandBtn10 = self.makeBtn("parenttest",        self.deleteParentKeepChildren)            
                                    
                                    
                                            cmds.showWindow()        
                                            
                                        def makeBtn(self, *args):
                                            cmds.separator(height=2, style="none")
                                            self.loadSettingsBtn = cmds.button(label=args[0], command=args[1])
                                    
                                    
                                        # Not done yet
                                        def deleteParentKeepChildren(self, *args):
                                            delItems = ["pSphere1", "pSphere2"]
                                            for item in delItems:
                                                grandParent = cmds.listRelatives(item, parent=True)[0]
                                                # if not grandparent use w=True
                                                childrenList = cmds.listRelatives(item, children=True)
                                                #print (grandParent)
                                                for child in childrenList:
                                                    #print(child)
                                                    cmds.parent(child, w=True)
                                                    cmds.parent(child, grandParent)
                                                cmds.delete(item)
                                    
                                    
                                    
                                    
                                        def addDeleteObjs(self, *args):
                                            self.delObjs += cmds.ls(selection=True)
                                            self.delObjs = list(set(self.delObjs))
                                            print ("Deleting objects:")
                                            print (self.delObjs)
                                            
                                        def promptDialog(self, title, message, buttons=[], defaultButton="Yes", cancelButton="No", dismissString="No"):
                                            result = cmds.confirmDialog( title=title, message=message, button=buttons, defaultButton=defaultButton, cancelButton=cancelButton, dismissString=dismissString)
                                            if result == defaultButton:
                                                return True
                                            cmds.warning(message)
                                            return False
                                    
                                    
                                        def addSaveObjs(self, *args):
                                            self.saveObjs += cmds.ls(selection=True)
                                            self.saveObjs = list(set(self.saveObjs))
                                            print ("Saving objects:")
                                            print (self.saveObjs)
                                            
                                    
                                        def deleteObjs(self, *args):
                                            if self.activeProfile:
                                                mySel = cmds.ls(self.activeProfile['delObjs'])
                                                mySel2 = cmds.select(mySel, hi=self.deleteChildren)
                                                print (mySel)
                                                print (mySel2)
                                                cmds.delete()
                                            else:
                                                print ("no active profile")
                                            
                                    
                                        def restartWindow(self, *args):
                                            #delete window if exists
                                            wName = self.window.replace(" ", "_")
                                            if cmds.window(wName, exists = True):
                                                cmds.deleteUI(wName)
                                                 
                                    
                                    
                                        def debugtestWarning(self, *args):
                                            print (self.promptDialog("test title", "message", ["Yes", "No"], "Yes", "No", "No")    )      
                                            print (self.promptDialog("test title", "message")   )
                                            
                                    
                                    def undoChunkOpen(open=True):
                                        # opens and closes a undo section
                                        if not open:
                                            print ("Undo chunk closed")
                                            cmds.undoInfo(chunkName="state", closeChunk=True)
                                            return
                                        print ("Undo chunk opened")
                                        cmds.undoInfo(chunkName="state", openChunk=True)
                                    
                                    
                                    def saveJson(path, data):
                                        print ("saving json file")
                                        with open(path, 'w') as outfile:
                                            json.dump(data, outfile)
                                        
                                    
                                    def loadJson(path):
                                        with open(path, "r") as json_file:
                                            return json.load(json_file)
                                    
                                    
                                    def getFBXSettings():
                                        # get current user settings for FBX export and store them
                                        mel.eval('FBXPushSettings;')
                                    
                                    
                                    def setFBXSettings():
                                        # set user-defined FBX settings back after export
                                        mel.eval('FBXPopSettings;')
                                    
                                    def exportFBX(exportFileName, min_time=False, max_time=False):
                                        # export selected as FBX
                                    
                                        # store current user FBX settings
                                        getFBXSettings()
                                    
                                        # Geometry
                                        mel.eval("FBXExportSmoothingGroups -v true")
                                        mel.eval("FBXExportHardEdges -v false")
                                        mel.eval("FBXExportTangents -v false")
                                        mel.eval("FBXExportSmoothMesh -v true")
                                        mel.eval("FBXExportInstances -v false")
                                        mel.eval("FBXExportReferencedAssetsContent -v false")
                                        mel.eval("FBXExportAnimationOnly -v false")
                                        mel.eval("FBXExportBakeComplexAnimation -v true")
                                        if not min_time == False:
                                            mel.eval("FBXExportBakeComplexStart -v " + str(min_time))
                                        if not max_time == False:
                                            mel.eval("FBXExportBakeComplexEnd -v " + str(max_time))
                                        mel.eval("FBXExportBakeComplexStep -v 1")
                                        mel.eval("FBXExportUseSceneName -v false")
                                        mel.eval("FBXExportQuaternion -v euler")
                                        mel.eval("FBXExportShapes -v true")
                                        mel.eval("FBXExportSkins -v true")
                                        # Constraints
                                        mel.eval("FBXExportConstraints -v false")
                                        # Cameras
                                        mel.eval("FBXExportCameras -v false")
                                        # Lights
                                        mel.eval("FBXExportLights -v false")
                                        # Embed Media
                                        mel.eval("FBXExportEmbeddedTextures -v false")
                                        # Connections
                                        mel.eval("FBXExportInputConnections -v false")
                                        # Axis Conversion
                                        mel.eval("FBXExportUpAxis y")
                                        # Version
                                        mel.eval("FBXExportFileVersion -v FBX201600")
                                        mel.eval("FBXExportInAscii -v true")
                                        cmds.file(exportFileName, exportSelected=True, type="FBX export", force=True, prompt=False)
                                        # restore current user FBX settings
                                        setFBXSettings()
                                    
                                    myWindow = MR_Window()
                                    
                                

+

Asset manager


Another issue that arose during Albions development was related to asset management, as each environmental prop had 9+ versions (For different tiers + biome variations) plus meta files. Each time one asset needed to be moved this meant that the artists had to go into 9 folders to rename 18 different files. The tool created allowed them to select one of the files and input the new name.
The tool would then return the suggested actions which the user could accept or deny and lastly save all changes done to a logfile. The main challenge was that certain parts of some asset strings had to be excluded so a lot of regex had to be applied to different parts of the folder structures.

Screenshot of tool

+

Level assembler


This tool was created for Albion Online to improve the workflow of level designers, unfortunately I cannot share screenshots or videos of it but will provide a description of the issue and solution.

Background

The game takes place in one single world without instancing, this is great for immersion but not so much for scalability without a robust system. The game world consists of hundreds of 1x1km maps with loading zones in between, early during the games development each of these maps were designed and built by hand, each asset placed by hand.

As the game went through a phase of heavy growth (10x+ increase of playerbase) this was deemed to not be sustainable anymore.

old setup
Old level setup

Templates and Layers

The solution to this was the creation of two systems: templates and layers.
Templates purpose was to allow for levels to instead be assembled by already built pieces of levels. The main component was what we called the "main template" which had slots for other templates but generally not a lot of gameplay, it was the glue that tied the whole level together. Each main template also had layers baked in, these contained variations of the levels as well as potential roads.

main template
Main template



After that came the border templates, these also used the layer system which decided where each exit was located.

border templates
Border templates



The holes in the main template were what the majority of the content of the game took place:

  • Monster camps
  • Castles & Guild territories
  • Treasures
  • Dungeon entrances
  • Resource hotspots

content templates
Content templates



After that came roads, which existed to guide players to the different exits of the level, they were tied together with tiny 4x4 meter crossing templates.

The result was something like the following (minimap):

completed level minimap
Complete templated level



The remaining problem and following solution

Our new system with templates and layers put us in a vastly improved position compared to our early fully hand-crafted solution. The one remaining issue was that it wasn't fast enough to assemble the levels due to unity struggling with moving many thousands of objects around at once to assemble the levels.

The solution became a toolset I developed which fetched the location of all slots in a main template as well as the centerpoint of each other template. The data was was dumped in a google sheet, one tab for the data and one for the assembler.
The level designer would be able to use dropdowns and see a preview of each template within the sheet. It also used djikstras (pathfinding) alghoritm to decide the possible road setup layers depending on what exits were selected.
Finally the designer could select export to get a level JSON to add to the unity project. The process going from 15-30 minutes to less than a minute while being a lot less error prone.

Houdini

When I started my first job as a level designer we had a entirely manual approach to creating levels, each level handcrafted, each stone and tree placed by hand one (or multiple) clicks at a time to the detriment of my wrists and sanity. After roughly half a year I came to the conclusion that automation for placement and other such things were needed, I hadn't at that time come in contact with idea of procedural workflows yet but if I had I would have tried solving our pain at that point already.

Fast forward a few years and I started playing around with Houdini, at first just for terrains but later for asset assembly and mesh generation. I really think that the non-destructive nature that allows for easy revisions and ability to easily reuse and apply subnetworks/HDAs is an amazing time saver while also ensuring a more even base level quality.

+

Terrains & World creator


While houdini provideds great tools for scattering assets on heightfields and for very exact control the workflow and steps required to properly handle the heightfields themselves felt a bit lacking.

Terrains generated in Houdini
Terrains generated in Houdini



As a result I looked into other options, and world creator seemingly being the most powerful tool. The downside is however quite clear as it would add another package to the pipeline where there is less script support or similar. Yet for certain projects I strongly believe a tool like it for the actual terrain generation plus Houdini for base scattering of foliage and additional details (roads, rivers etc) would lead to great results.

terrains created in world creator
Terrains made in world creator 2022



Here I've taken the terrain created above in world creator and scattered some very simple meshes representing trees and generated a mesh for a river. One of my current projects is looking into adjusting the terrain heightmap for rivers in Houdini instead of just taking the river defined in WC, the reason being that it is easier to create cascading effects and procedurally placing particle effects among other things.

houdini scatter objects on terrain
Scattering of objects on terrain from world creator



Taking this workflow further I also imagine the best solution would be to output masks for each biome in world creator then using the masks for asset placement rules in Houdini (or unreal/maya using Houdini engine).


+

Powerlines & Cables


The following is some experimentation at creating a cable and a powerline generator, the idea being that you can draw a curve on a terrain, drag a few sliders and have it output both the powerlines and remove the bigger foliage that wouldn't be allowed to exist in proximity.



Here the trees have been extracted by the same curve used to generate the powerlines.

Terrain with trees subtracted by tools



The next step would be to add a wire solver to create the wires. I already completed the solver solution but need a way of identifying what points on the lines are attached to the poles, something to explore in the future.

Unreal Engine

The projects I've been tinkering with recently have been mainly focused on playing around with unreal features and making a small prototype. It uses GAS (Epics Gameplay Ability System) to better deal with all the game’s abilities, cooldowns and spell costs in multiplayer and allows me to experiment faster with the engine features I am interested in.
Most of the level meshes and characters are coming from various packs.

Effects

For the effects I have used a mix of Houdini/Maya for meshes and Substance Designer and Photoshop for the textures. Generally the effects are a mix of various resources, forum posts and tutorials.

+

Lightning bolt


The idea was to fit the effects within the art style of the environment/character models at hand, heavily stylized.



The Houdini mesh generator is very simple but allows for quick creation of additional shapes.



The Substance setup is simply a few shapes squeezed into a line, warped and then subtracted with some tile samplers.

texture setup
Texture setup



The Niagara system has a few components:

  • Downward initial lightning bolt
  • Delayed upward bolt
  • Delayed ground lightning, 2 separate components
  • Sparks with and without collisions (CPU/GPU)
  • Impact rock fragments
  • Flash and glow

Niagara system
Niagara system setup



The material for the lightning bolt is very simple. The few moving parts of it is are:

  • A perlin noise that pans over the UV of the texture.
  • A setting for pan speed.
  • A cell noise for eroding the material.

Each setting controlled by dynamic parameters from the niagara system.

Material setup
Material setup


+

Fireball


The effect is made with very simple shapes available in Unreal plus a basic tilable fire texture.
Is split in 3 different niagara systems that are triggered by the ability blueprint and in turn animation montage:

  • Ground effect: (triggered when casting) by the blueprint.
  • Fireball spawns from the hand socket with an offset once the gameplay event triggers in the animation montage.
  • Explosion triggers on fireball collision.

Niagara setup
Niagara system ground cast setup


Niagara setup
Niagara system explosion cast setup

+

Water material

Another experiment was my water material which I wanted to keep stylized but still add some depth to it.
The result was two materials, one expensive solution using subsurface scattering and one cheaper opaque version which just has some foam for objects cutting through.

Expensive solution:

Cheap solution:


← Back