Intial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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)
|
||||
Reference in New Issue
Block a user