Barrels and boxes

Shoot alternating barrels and boxes into a physics scene. Click the canvas to aim, use the left button for one shot or hold the right button for automatic fire. Escape releases the mouse.

Each projectile pairs a physics body with a child model. Interpolation keeps motion smooth, and the oldest projectile is removed after the limit of 64.


    
main.lua
local natiny = require("natiny")
local demo = require("common.modules.demo")

local BG     = {  21 / 255,  30 / 255,  44 / 255 } -- #151E2C

-- The deferred scene needs an explicit window background: its full-screen
-- composite otherwise turns untouched G-buffer pixels black.
local COMPOSITE = [[
attribute vec3 position;
attribute vec2 texcoord0;

varying vec2 uv;

uniform mat4 mtx_worldviewproj;
uniform sampler2D hdr;
uniform sampler2D g_albedo;
uniform sampler2D g_ao;
uniform sampler2D g_position;
uniform vec4 tone_params;
uniform vec4 background_color;

void vertex()
{
    uv = texcoord0;
    POSITION = mtx_worldviewproj * vec4(position, 1.0);
}

void fragment()
{
    vec3 color  = textureLod(hdr, uv, 0.0).rgb;
    vec3 albedo = textureLod(g_albedo, uv, 0.0).rgb;
    float ao = textureLod(g_ao, uv, 0.0).r;

    color *= mix(1.0, ao, tone_params.z);
    color += albedo * tone_params.y * ao;
    color *= tone_params.x;
    color  = color / (color + vec3(1.0));
    color  = pow(color, vec3(1.0 / 2.2));

    float geometry = step(0.5, textureLod(g_position, uv, 0.0).a);
    COLOR = vec4(mix(background_color.rgb, color, geometry), 1.0);
}
]]

local function inline_shader(source)
    local resource = natiny.resource.from_data(source)
    local shader = natiny.shader.load(resource)
    natiny.resource.destroy(resource)
    return shader
end

if not natiny.backend.init(arg and arg[1] or natiny.backend.AUTO) then
    os.exit(1)
end
if not natiny.physics.init() then
    os.exit(1)
end

--////// rendering pipeline

local function set_fullscreen_state(material)
    natiny.material.set_state(material, { blend = natiny.material.BLEND_NONE, depth = natiny.material.DEPTH_OFF })
end

-- Every file the pipeline is made of comes from the shell, which already
-- holds the bytes by the time this runs.
local function create_rendering_pipeline(width, height, options)
    local light = options.light
    local shadow_options = options.shadow
    local ao = options.ao

    local materials = {
        geometry = natiny.material.create(natiny.shader.load(demo:resource("data/examples/barrels_and_boxes/shaders/gbuffer.nsl")), "scene"),
        shadow = natiny.material.create(natiny.shader.load(demo:resource("data/examples/barrels_and_boxes/shaders/shadow.nsl"))),
        ssao = natiny.material.create(natiny.shader.load(demo:resource("data/examples/barrels_and_boxes/shaders/ssao.nsl"))),
        light = natiny.material.create(natiny.shader.load(demo:resource("data/examples/barrels_and_boxes/shaders/light.nsl"))),
        composite = natiny.material.create(inline_shader(COMPOSITE)),
    }
    local blur_shader = natiny.shader.load(demo:resource("data/examples/barrels_and_boxes/shaders/ssao_blur.nsl"))
    materials.blur_x = natiny.material.create(blur_shader)
    materials.blur_y = natiny.material.create(blur_shader)

    local roughness_texture = natiny.texture.create(demo:resource(options.roughness_texture))

    natiny.material.set_state(materials.geometry, { blend = natiny.material.BLEND_NONE })
    natiny.material.set_state(materials.shadow, { blend = natiny.material.BLEND_NONE, cull = natiny.material.CULL_NONE })
    for _, material in ipairs({ materials.ssao, materials.blur_x, materials.blur_y, materials.light, materials.composite }) do
        set_fullscreen_state(material)
    end

    natiny.material.set_texture(materials.geometry, "roughness_map", roughness_texture)
    natiny.material.set_constant(materials.geometry, "surface_params", options.metallic, 0, 0, 0)
    natiny.material.set_constant(materials.ssao, "ao_params", ao.radius, ao.strength, ao.bias, math.tan(math.rad(options.camera_fov) * 0.5))
    natiny.material.set_constant(materials.ssao, "ao_curve", ao.curve, 0, 0, 0)
    natiny.material.set_constant(materials.blur_x, "blur_params", 1, 0, ao.blur_tolerance, 0)
    natiny.material.set_constant(materials.blur_y, "blur_params", 0, 1, ao.blur_tolerance, 0)
    natiny.material.set_constant(materials.light, "light_pos", light.x, light.y, light.z, 0)
    natiny.material.set_constant(materials.light, "light_color", light.r, light.g, light.b, light.power)
    natiny.material.set_constant(materials.light, "shadow_params", shadow_options.normal_bias, shadow_options.depth_bias, 1 / shadow_options.face_size, 0)
    natiny.material.set_constant(materials.composite, "tone_params", options.tone.exposure, options.tone.ambient, options.tone.ao_visibility, 0)
    natiny.material.set_constant(materials.composite, "background_color",
        BG[1], BG[2], BG[3], 1)

    local shadow = natiny.surface.create(shadow_options.face_size * 6, shadow_options.face_size, natiny.surface.RGBA16F, true)
    local shadow_cameras = {}
    local face_angles = {
        {   0, -90, 0 }, {   0,  90, 0 },
        {  90,   0, 0 }, { -90,   0, 0 },
        {   0, 180, 0 }, {   0,   0, 0 },
    }

    for face, angles in ipairs(face_angles) do
        local camera = natiny.camera.create()
        natiny.camera.set_fov(camera, 90)
        natiny.camera.set_clip(camera, shadow_options.near_clip, shadow_options.far_clip)
        natiny.entity.set_position(camera, natiny.entity.SPACE_WORLD, light.x, light.y, light.z)
        natiny.entity.set_rotation(camera, natiny.entity.SPACE_WORLD, angles[1], angles[2], angles[3])
        shadow_cameras[face] = camera
    end

    local function ao_size(w, h)
        return math.max(1, math.floor(w / ao.resolution_scale)),
               math.max(1, math.floor(h / ao.resolution_scale))
    end

    local ao_width, ao_height = ao_size(width, height)
    local pipeline = {
        width = width,
        height = height,
        material = materials.geometry,
        roughness_texture = roughness_texture,
        albedo = natiny.surface.create(width, height, natiny.surface.RGBA8, true),
        normal = natiny.surface.create(width, height, natiny.surface.RGBA16F, false),
        position = natiny.surface.create(width, height, natiny.surface.RGBA16F, false),
        lighting = natiny.surface.create(width, height, natiny.surface.RGBA16F, false),
        occlusion = natiny.surface.create(ao_width, ao_height, natiny.surface.RGBA16F, false),
        occlusion_blur = natiny.surface.create(ao_width, ao_height, natiny.surface.RGBA16F, false),
    }

    function pipeline:connect()
        for name, target in pairs({ g_albedo = self.albedo, g_normal = self.normal, g_position = self.position, g_shadow = shadow }) do
            natiny.material.set_texture(materials.light, name, natiny.surface.get_texture(target))
        end

        natiny.material.set_texture(materials.ssao, "g_normal", natiny.surface.get_texture(self.normal))
        natiny.material.set_texture(materials.ssao, "g_position", natiny.surface.get_texture(self.position))
        natiny.material.set_constant(materials.ssao, "ao_source", self.width, self.height, 1 / self.width, 1 / self.height)
        natiny.material.set_texture(materials.blur_x, "g_ao", natiny.surface.get_texture(self.occlusion))
        natiny.material.set_texture(materials.blur_y, "g_ao", natiny.surface.get_texture(self.occlusion_blur))

        for name, target in pairs({ hdr = self.lighting, g_albedo = self.albedo,
                                    g_ao = self.occlusion, g_position = self.position }) do
            natiny.material.set_texture(materials.composite, name, natiny.surface.get_texture(target))
        end
    end

    function pipeline:resize(new_width, new_height)
        if new_width == self.width and new_height == self.height then return end
        self.width, self.height = new_width, new_height

        for _, target in ipairs({ self.albedo, self.normal, self.position, self.lighting }) do
            natiny.surface.resize(target, new_width, new_height)
        end

        local new_ao_width, new_ao_height = ao_size(new_width, new_height)
        natiny.surface.resize(self.occlusion, new_ao_width, new_ao_height)
        natiny.surface.resize(self.occlusion_blur, new_ao_width, new_ao_height)
        self:connect()
    end

    function pipeline:draw_shadows(draw_scene)
        natiny.surface.bind(shadow, function()
            natiny.render.clear(1000, 0, 0, 1)
            natiny.material.bind(materials.shadow, function()
                for face, camera in ipairs(shadow_cameras) do
                    natiny.render.viewport_begin((face - 1) * shadow_options.face_size, 0, shadow_options.face_size, shadow_options.face_size)
                    natiny.camera.bind(camera, draw_scene)
                    natiny.render.viewport_end()
                end
            end)
        end)
    end

    function pipeline:render(camera, draw_scene)
        self:draw_shadows(draw_scene)

        natiny.surface.bind(self.albedo, 0)
        natiny.surface.bind(self.normal, 1)
        natiny.surface.bind(self.position, 2)
        natiny.render.clear(0, 0, 0, 0)
        natiny.camera.bind(camera, draw_scene)
        natiny.surface.unbind()

        natiny.surface.bind(self.occlusion, function()
            natiny.material.bind(materials.ssao, natiny.render.cover)
        end)
        natiny.surface.bind(self.occlusion_blur, function()
            natiny.material.bind(materials.blur_x, natiny.render.cover)
        end)
        natiny.surface.bind(self.occlusion, function()
            natiny.material.bind(materials.blur_y, natiny.render.cover)
        end)
        natiny.surface.bind(self.lighting, function()
            natiny.render.clear(0, 0, 0, 1)
            natiny.material.bind(materials.light, natiny.render.cover)
        end)
        natiny.material.bind(materials.composite, natiny.render.cover)
    end

    pipeline:connect()
    return pipeline
end

--////// demo scene

local WINDOW = { width = 1280, height = 720 }
local CAMERA = {
    x = 0, y = 1.6, z = 0,
    fov = 65,
    sensitivity = 0.15,
}
local LIGHT = {
    x = 0, y = 2.5, z = 0,
    r = 1.0, g = 0.92, b = 0.82,
    power = 1000,
}
local SHOT = {
    spawn_distance = 0.8,
    impulse = 24,
    friction = 0.6,
    restitution = 0.25,
    mass = 20,
    limit = 64,
    auto_fire_interval = 0.12,
}
local BARREL = { radius = 0.534 / 2, height = 0.858 }
local BOX = { size = 0.5 }
local RENDERING = {
    camera_fov = CAMERA.fov,
    light = LIGHT,
    roughness_texture = "data/examples/barrels_and_boxes/textures/roughness.png",
    metallic = 0,
    shadow = {
        face_size = 256,
        near_clip = 0.05,
        far_clip = 20,
        normal_bias = 0.03,
        depth_bias = 0.02,
    },
    ao = {
        resolution_scale = 4,
        radius = 0.5,
        strength = 3.0,
        bias = 0.1,
        curve = 2.0,
        blur_tolerance = 0.05,
    },
    tone = {
        exposure = 1.4,
        ambient = 0.12,
        ao_visibility = 1.0,
    },
}

--  Six shaders, a roughness map and three models: enough that the wait is
--  worth showing, which is what the shell is for.
local ASSETS = {
    "data/examples/barrels_and_boxes/shaders/gbuffer.nsl",
    "data/examples/barrels_and_boxes/shaders/shadow.nsl",
    "data/examples/barrels_and_boxes/shaders/ssao.nsl",
    "data/examples/barrels_and_boxes/shaders/ssao_blur.nsl",
    "data/examples/barrels_and_boxes/shaders/light.nsl",
    "data/examples/barrels_and_boxes/textures/roughness.png",
    "data/examples/barrels_and_boxes/models/demo_scene.glb",
    "data/common/models/barrel.glb",
    "data/common/models/box_01.glb",
}

local window = natiny.window.create("Natiny", WINDOW.width, WINDOW.height)
natiny.window.set_scale_mode(window, natiny.window.SCALE_MODE_STRETCH)

--  All of these are made out of the files above, so they are made in the
--  build below rather than here.
local pipeline, scene, barrel_mesh, box_mesh, projectile_types

local camera = natiny.camera.create()
local yaw, pitch = 0, 0
natiny.camera.set_fov(camera, CAMERA.fov)
natiny.entity.set_position(camera, natiny.entity.SPACE_WORLD, CAMERA.x, CAMERA.y, CAMERA.z)

--  Turning only while the pointer is locked.  Unlocked, the cursor is a
--  cursor: it stops at the edge of the window, so a turn would stop with
--  it, and it belongs to whatever is behind the window anyway.
local function update_camera(captured)
    if captured then
        yaw = yaw - natiny.input.get_mouse_x_speed() * CAMERA.sensitivity
        pitch = pitch - natiny.input.get_mouse_y_speed() * CAMERA.sensitivity
        pitch = math.max(-89, math.min(89, pitch))
        natiny.entity.set_rotation(camera, natiny.entity.SPACE_LOCAL, pitch, yaw, 0)
    end

    local ry, rx = math.rad(yaw), math.rad(pitch)
    return -math.sin(ry) * math.cos(rx),
            math.sin(rx),
           -math.cos(ry) * math.cos(rx)
end

local projectiles = {}
local next_projectile = 1

local function shoot_projectile(dx, dy, dz)
    local projectile = projectile_types[next_projectile]
    local body = projectile.create_body()
    local model = natiny.model.create(projectile.mesh, pipeline.material)
    next_projectile = next_projectile % #projectile_types + 1

    natiny.entity.set_position(body, natiny.entity.SPACE_WORLD, CAMERA.x + dx * SHOT.spawn_distance, CAMERA.y + dy * SHOT.spawn_distance, CAMERA.z + dz * SHOT.spawn_distance)
    natiny.entity.set_parent(model, body)
    natiny.entity.set_position(model, natiny.entity.SPACE_LOCAL, 0, projectile.model_y, 0)
    natiny.physics.set_material(body, SHOT.friction, SHOT.restitution, SHOT.mass)
    natiny.physics.apply_impulse(body, dx * SHOT.impulse, dy * SHOT.impulse, dz * SHOT.impulse)
    natiny.physics.set_interpolation(body, true)

    projectiles[#projectiles + 1] = { body = body, model = model }
    if #projectiles > SHOT.limit then
        local oldest = table.remove(projectiles, 1)
        natiny.model.destroy(oldest.model)
        natiny.physics.destroy(oldest.body)
    end
end

local function draw_scene()
    natiny.render.tag("scene")
end

local MOUSE_LEFT = natiny.input.MOUSE_BUTTON_LEFT
local MOUSE_RIGHT = natiny.input.MOUSE_BUTTON_RIGHT
local auto_fire_cooldown = 0

local function frame(dt)
    natiny.window.bind(window, function()
        local captured = natiny.input.get_mouse_captured()
        demo:set_user_text(captured
            and "Mouse: look  |  LMB: shoot  |  Hold RMB: automatic fire  |  Esc: free the mouse"
            or "Click to take the mouse")

        --  The click that takes the mouse does not also fire: it is the click
        --  the browser wants before it will grant a pointer lock at all, and a
        --  barrel spawned by it would come as a surprise.  Escape gives the
        --  cursor back -- the engine does that itself, and on both platforms,
        --  because in the browser it has no say in the matter.
        if not captured then
            if natiny.input.get_mouse_button_pressed(MOUSE_LEFT) then
                natiny.input.set_mouse_capture(true)
            end
            auto_fire_cooldown = 0
        end

        local dx, dy, dz = update_camera(captured)

        if captured then
            if natiny.input.get_mouse_button_pressed(MOUSE_LEFT) then
                shoot_projectile(dx, dy, dz)
            end

            if natiny.input.get_mouse_button_down(MOUSE_RIGHT) then
                auto_fire_cooldown = auto_fire_cooldown - dt
                if auto_fire_cooldown <= 0 then
                    shoot_projectile(dx, dy, dz)
                    auto_fire_cooldown = SHOT.auto_fire_interval
                end
            else
                auto_fire_cooldown = 0
            end
        end

        pipeline:resize(natiny.window.get_width(window), natiny.window.get_height(window))
        pipeline:render(camera, draw_scene)
    end)
end

--  Everything above is either a number or a function; on_loaded is where the
--  files turn into the scene.
local function build_scene()
    pipeline = create_rendering_pipeline(
        natiny.window.get_width(window), natiny.window.get_height(window), RENDERING)

    local scene_mesh = natiny.mesh.load(demo:resource("data/examples/barrels_and_boxes/models/demo_scene.glb"))
    scene = {
        model = natiny.model.create(scene_mesh, pipeline.material),
        body = natiny.physics.mesh(scene_mesh),
    }

    barrel_mesh = natiny.mesh.load(demo:resource("data/common/models/barrel.glb"))
    box_mesh = natiny.mesh.load(demo:resource("data/common/models/box_01.glb"))

    projectile_types = {
        {
            mesh = barrel_mesh,
            create_body = function() return natiny.physics.cylinder(BARREL.radius, BARREL.height) end,
            model_y = -BARREL.height / 2,
        },
        {
            mesh = box_mesh,
            create_body = function() return natiny.physics.cube(BOX.size, BOX.size, BOX.size) end,
            model_y = 0,
        },
    }
end

demo:run({
    window = window,
    title  = "Barrels and boxes",
    resources = ASSETS,

    on_loaded = build_scene,
    loop = frame,
})

natiny.backend.shutdown()