Intial Commit

This commit is contained in:
2026-10-27 17:27:44 -04:00
parent 1fa6e14bde
commit 5743c34a21
49 changed files with 1454 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Functional Turrets release artwork
Original distressed survival-poster layout, using the actual modified KI5 vehicle meshes rendered in Blender. HRSys accent: `#F16465`. No AI image generation was used. The Ferret and LAV-300 meshes originate from KI5; this artwork does not change their ownership or permissions.
- `preview.png`: 1024 x 1024 Workshop and mod preview.
- `icon.png`: 512 x 512 transparent shared mod icon.
- `preview-master.png`: 2048 x 2048 editable composition output.
- `icon-master.png`: 1024 x 1024 transparent master.
- `*-release-render.blend`: prepared scenes with geometry, procedural materials, lights and orthographic cameras. Open in Blender to change the renders.
- `*-turret-render.png`: transparent render inputs for the composition.
To rebuild the composition on Windows with Python, Pillow, NumPy, and the Impact/Courier Bold Windows fonts:
```powershell
python compose_release_art.py --output-dir .
```
The original `render_release_turrets.py` is included as process source; it expects the development `work/lav_turret_rig.blend` and `work/ferret_turret_rig.blend` inputs. The included prepared `.blend` scenes can be rendered directly without those inputs.
The deployed images are also copied into each mod's root and `42.20` directories. Update those copies and the project root `preview.png` after changing the artwork.
+22
View File
@@ -0,0 +1,22 @@
{
"style": "Original distressed survival-poster treatment; actual game-model renders",
"pink": "#F16465",
"preview_master": [
2048,
2048
],
"preview": [
1024,
1024
],
"icon_master": [
1024,
1024
],
"icon": [
512,
512
],
"icon_transparent": true,
"no_image_generation": true
}
+80
View File
@@ -0,0 +1,80 @@
"""Compose the release art from native Blender renders and original typography."""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageChops, ImageOps
import numpy as np, math, json, argparse
work=Path(__file__).parent
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output-dir',type=Path,default=work.parent/'outputs/FunctionalTurrets-release-art')
out=parser.parse_args().output_dir
W=2048;coral=(241,100,101);cream=(232,226,209);rng=np.random.default_rng(271026)
impact=Path(r'C:\Windows\Fonts\impact.ttf');mono=Path(r'C:\Windows\Fonts\courbd.ttf')
font=lambda n:ImageFont.truetype(str(impact),n)
small=lambda n:ImageFont.truetype(str(mono),n)
def fit(text,maxwidth,maxsize,fontpath=impact):
for n in range(maxsize,10,-1):
f=ImageFont.truetype(str(fontpath),n)
if f.getlength(text)<=maxwidth:return f
def cutout(path):
im=Image.open(path).convert('RGBA');return im.crop(im.getchannel('A').getbbox())
def arrows(layer,box,color,width):
d=ImageDraw.Draw(layer);cx=(box[0]+box[2])/2;cy=(box[1]+box[3])/2;rx=(box[2]-box[0])/2;ry=(box[3]-box[1])/2
for start,end in [(12,155),(195,338)]:
d.arc(box,start,end,fill=color,width=width)
a=math.radians(end);x=cx+rx*math.cos(a);y=cy+ry*math.sin(a)
tangent=np.array([-rx*math.sin(a),ry*math.cos(a)]);tangent=tangent/np.linalg.norm(tangent)
side=np.array([-tangent[1],tangent[0]])
tip=np.array([x,y])+tangent*width*1.6;base=np.array([x,y])-tangent*width*1.9
d.polygon([tuple(tip),tuple(base+side*width*1.55),tuple(base-side*width*1.55)],fill=color)
def distressed_text(canvas,position,text,typeface,color):
mask=Image.new('L',canvas.size);d=ImageDraw.Draw(mask);d.text(position,text,font=typeface,fill=255,anchor='lt')
a=np.asarray(mask).copy();noise=rng.random(a.shape)
a[(noise<.025)&(a>0)]=30
# Thin uneven scratches cut through the lettering without sacrificing shape.
scratches=Image.new('L',canvas.size);sd=ImageDraw.Draw(scratches)
for _ in range(40):
x=int(rng.integers(70,W-70));y=int(rng.integers(170,650));sd.line((x,y,x+int(rng.integers(5,100)),y+int(rng.integers(-3,4))),fill=150,width=int(rng.integers(1,3)))
mask=ImageChops.subtract(Image.fromarray(a),scratches)
canvas.alpha_composite(Image.composite(Image.new('RGBA',canvas.size,(*color,255)),Image.new('RGBA',canvas.size),mask))
# Weathered dark olive paper, with a restrained warm vignette.
yy,xx=np.mgrid[0:W,0:W];rad=((xx/W-.5)**2+(yy/W-.5)**2)
grain=rng.normal(0,2.3,(W,W));coarse=Image.fromarray(rng.integers(0,255,(64,64),dtype=np.uint8)).resize((W,W),Image.Resampling.BICUBIC)
tone=(np.asarray(coarse).astype(float)-127)/35
background=np.stack([22+grain+tone-10*rad,27+grain+tone-12*rad,24+grain+tone-11*rad],axis=2)
poster=Image.fromarray(np.clip(background,0,255).astype('uint8')).convert('RGBA')
texture=Image.new('RGBA',poster.size);td=ImageDraw.Draw(texture)
for x in range(82,W,128):td.line((x,725,x,1770),fill=(190,190,160,9),width=1)
for y in range(770,1790,128):td.line((85,y,1960,y),fill=(190,190,160,9),width=1)
for _ in range(180):
x,y=rng.integers(15,W-15,2);td.line((int(x),int(y),int(x+rng.integers(-2,3)),int(y+rng.integers(2,55))),fill=(150,145,120,int(rng.integers(5,20))),width=1)
td.rectangle((36,36,2011,2011),outline=(213,209,185,40),width=2)
poster.alpha_composite(texture)
distressed_text(poster,(88,175),'FUNCTIONAL',fit('FUNCTIONAL',1872,262),cream)
distressed_text(poster,(82,384),'TURRETS',fit('TURRETS',1884,377),cream)
ferret=cutout(out/'ferret-turret-render.png');lav=cutout(out/'lav-turret-render.png')
ferret=ImageOps.contain(ferret,(840,890),Image.Resampling.LANCZOS)
lav=ImageOps.contain(lav,(1260,1020),Image.Resampling.LANCZOS)
def place(im,x,y):
shadow=Image.new('RGBA',poster.size);a=im.getchannel('A').filter(ImageFilter.GaussianBlur(9))
shade=Image.new('RGBA',im.size,(0,0,0,0));shade.putalpha(a.point(lambda p:int(p*.65)))
shadow.alpha_composite(shade,(x+14,y+24));poster.alpha_composite(shadow);poster.alpha_composite(im,(x,y))
place(ferret,82,822)
place(lav,1945-lav.width,775)
poster.convert('RGB').save(out/'preview-master.png')
poster.convert('RGB').resize((1024,1024),Image.Resampling.LANCZOS).save(out/'preview.png')
# Shared icon: transparent model silhouette and rotation arrows, no tiny text.
icon=Image.new('RGBA',(1024,1024));ring=Image.new('RGBA',icon.size)
arrows(ring,(90,637,930,960),(*coral,240),24);icon.alpha_composite(ring)
model=ImageOps.contain(cutout(out/'lav-turret-render.png'),(880,865),Image.Resampling.LANCZOS)
a=model.getchannel('A');outline=ImageChops.subtract(a.filter(ImageFilter.MaxFilter(9)),a)
outline_layer=Image.new('RGBA',model.size,(*cream,255));outline_layer.putalpha(outline.point(lambda p:int(p*.78)))
x=(1024-model.width)//2;y=32
icon.alpha_composite(outline_layer,(x,y));icon.alpha_composite(model,(x,y))
icon.save(out/'icon-master.png');icon.resize((512,512),Image.Resampling.LANCZOS).save(out/'icon.png')
icon.resize((128,128),Image.Resampling.LANCZOS).save(out/'icon-128.png')
poster.convert('RGB').resize((256,256),Image.Resampling.LANCZOS).save(out/'preview-256.png')
(out/'artwork.json').write_text(json.dumps({'style':'Original distressed survival-poster treatment; actual game-model renders','pink':'#F16465','preview_master':[2048,2048],'preview':[1024,1024],'icon_master':[1024,1024],'icon':[512,512],'icon_transparent':True,'no_image_generation':True},indent=2))
print('Composed release preview and transparent icon:',out)
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+59
View File
@@ -0,0 +1,59 @@
"""Release-art model renders. Uses existing meshes, no image generation."""
import bpy, bmesh, json, math
from pathlib import Path
from mathutils import Vector
work=Path(__file__).parent
out=work.parent/'outputs/FunctionalTurrets-release-art'
out.mkdir(parents=True,exist_ok=True)
def linear(x):return x/12.92 if x<=0.04045 else ((x+0.055)/1.055)**2.4
def rgba(hex):return tuple(linear(int(hex[i:i+2],16)/255) for i in (0,2,4))+(1,)
for kind in ['lav','ferret']:
bpy.ops.wm.open_mainfile(filepath=str(work/('lav_turret_rig.blend' if kind=='lav' else 'ferret_turret_rig.blend')))
rig=bpy.data.objects['KLT_LAVSkeleton' if kind=='lav' else 'KFT_FerretSkeleton']
rig.animation_data_clear()
for bone in rig.pose.bones:bone.matrix_basis.identity()
upper=bpy.data.objects['KLT_LAVTurret' if kind=='lav' else 'KFT_FerretTurret']
keep=({'KLT_LAVTurret','KLT_LAVHatch','KLT_LAVViewports','KLT_LAVRadio','KLT_LAVInterior'} if kind=='lav' else {'KFT_FerretTurret','KFT_FerretHatch'})
for obj in list(bpy.context.scene.objects):
if obj.type=='MESH' and obj.name not in keep:bpy.data.objects.remove(obj,do_unlink=True)
group=upper.vertex_groups['body_bone'].index
fixed={v.index for v in upper.data.vertices if any(g.group==group and g.weight>.99 for g in v.groups)}
faces={p.index for p in upper.data.polygons if all(i in fixed for i in p.vertices)}
assert len(faces)==(52 if kind=='lav' else 16)
bm=bmesh.new();bm.from_mesh(upper.data);bm.faces.ensure_lookup_table()
bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.index in faces],context='FACES');bm.to_mesh(upper.data);bm.free()
mat=bpy.data.materials.new('HRSys distressed coral');mat.use_nodes=True
nodes=mat.node_tree.nodes;links=mat.node_tree.links;bsdf=nodes.get('Principled BSDF')
noise=nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=18;noise.inputs['Detail'].default_value=3.5;noise.inputs['Roughness'].default_value=.75
coords=nodes.new('ShaderNodeTexCoord');links.new(coords.outputs['Generated'],noise.inputs['Vector'])
ramp=nodes.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.22;ramp.color_ramp.elements[0].color=rgba('3e3330')
ramp.color_ramp.elements[1].position=.46;ramp.color_ramp.elements[1].color=rgba('e46060')
links.new(noise.outputs['Fac'],ramp.inputs[0]);links.new(ramp.outputs['Color'],bsdf.inputs['Base Color'])
bsdf.inputs['Roughness'].default_value=.84;bsdf.inputs['Metallic'].default_value=.08
bump=nodes.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.10;bump.inputs['Distance'].default_value=.025;links.new(noise.outputs['Fac'],bump.inputs['Height']);links.new(bump.outputs['Normal'],bsdf.inputs['Normal'])
for obj in bpy.context.scene.objects:
if obj.type=='MESH':obj.data.materials.clear();obj.data.materials.append(mat)
scene=bpy.context.scene;scene.render.engine='CYCLES';scene.cycles.samples=96;scene.cycles.use_denoising=True
scene.render.film_transparent=True;scene.render.image_settings.file_format='PNG';scene.render.image_settings.color_mode='RGBA';scene.render.image_settings.color_depth='8'
scene.view_settings.view_transform='Standard';scene.view_settings.look='None';scene.view_settings.exposure=-.15
scene.world.use_nodes=True;scene.world.node_tree.nodes['Background'].inputs[0].default_value=(.72,.77,.72,1);scene.world.node_tree.nodes['Background'].inputs[1].default_value=.32
scene.render.resolution_x=1800;scene.render.resolution_y=1800;scene.render.resolution_percentage=100
bpy.context.view_layer.update()
points=[o.matrix_world@Vector(c) for o in scene.objects if o.type=='MESH' for c in o.bound_box]
low=Vector(tuple(min(v[a] for v in points) for a in range(3)));high=Vector(tuple(max(v[a] for v in points) for a in range(3)));centre=(low+high)/2
size=max(high-low)
direction=Vector((.70,1,.67)) if kind=='lav' else Vector((-.8,1,.70))
bpy.ops.object.camera_add(location=centre+direction.normalized()*size*2.5)
cam=bpy.context.object;cam.rotation_euler=(centre-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.type='ORTHO';scene.camera=cam
rot=cam.rotation_euler.to_matrix();projected=[rot.transposed()@(v-centre) for v in points]
span=[max(p[a] for p in projected)-min(p[a] for p in projected) for a in range(2)]
midpoint=Vector(tuple((max(p[a] for p in projected)+min(p[a] for p in projected))/2 for a in range(2))+(0,))
cam.location+=rot@midpoint;cam.data.ortho_scale=max(span)*1.08
def light(name,position,energy,scale):
data=bpy.data.lights.new(name,'AREA');data.energy=energy*size*size;data.size=scale*size
obj=bpy.data.objects.new(name,data);scene.collection.objects.link(obj);obj.location=centre+Vector(position)*size;obj.rotation_euler=(centre-obj.location).to_track_quat('-Z','Y').to_euler()
light('Warm key',(-.6,.8,1.3),10,.65);light('Soft fill',(.8,.3,.5),3,.8);light('Rim',(.5,-1,1.1),13,.5)
scene.render.filepath=str(out/f'{kind}-turret-render.png');bpy.ops.render.render(write_still=True)
bpy.ops.wm.save_as_mainfile(filepath=str(out/f'{kind}-release-render.blend'))
print('RELEASE_RENDER',kind,out)