Skip to content

Instantly share code, notes, and snippets.

@DavidJCobb
Created July 19, 2019 22:22
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DavidJCobb/35506f8056c3c49538edfdad71e89dcd to your computer and use it in GitHub Desktop.
Save DavidJCobb/35506f8056c3c49538edfdad71e89dcd to your computer and use it in GitHub Desktop.
ESO Experiment/Rough Draft - Building and Rendering a Heightmap
--
-- This two-file add-on will track your position every tenth of a second
-- (or thirtieth when mounted, if I got the update re-registration right)
-- and maintain a grid of where you've been. Every fifth of a second, it
-- redraws the grid using Texture controls rendered in 3D as gridlines.
--
-- ISSUES:
--
-- - Performance (see below comments)
--
-- - No way to tell when the player is in midair, so jumping breaks the
-- tracking. Made it so that when revisiting a grid point, we always
-- prefer the lowest seen Y-coordinate; caveat is this breaks over-
-- hangs a bit
--
-- - The faster you move, the worse the problem becomes, because
-- actors/mounts catch air weirdly easily at speed in this game
--
-- NOTES:
--
-- - I uploaded this file to accompany a video of it in action, at
-- <https://youtu.be/RZTvQ_R9N6o>. This file is an experiment and a
-- first draft, using undocumented APIs that are seldom used or not
-- used even in Zenimax's own code. There are probably flaws and
-- bugs in how I manage controls, coordinate systems, and other
-- game-/UI-specific minutiae. Still, since you're here, hopefully
-- you get some use or entertainment out of it.
--
-- - The reason Maps are separated into Planes is to allow for under-
-- ground passages, caves, and relatively large overhangs; idea was
-- to have someone who is mapping out an area switch planes by hand
-- somehow, and then stitch planes together automatically when
-- exporting the data (currently no export or saving implemented yet)
--
-- - Based on Lib3D documentation and some ZOS API behaviors, the game
-- has AT LEAST three different 3D coordinate systems; the API does a
-- poor job of telegraphing what functions need what coordinates; we
-- track grid points (Coords) using whatever coordinate system is
-- used by GetUnitWorldPosition
--
-- - Speaking of, we rely on Lib3D for an event handler
--
-- - Original motivation was to be able to create detailed maps of a
-- zone, since the game's built-in world and zone maps are... well,
-- bad. The maps do a very poor job of displaying cliffs and other
-- impassable terrain, and if you're new to a zone or rarely visit
-- it, then that makes it a huge pain to reach a specific goal. (The
-- maps are stylized, but they sort of mimic a satellite photo? It's
-- like the worst of both words -- not stylized enough for cliffs to
-- be denoted with actual lines, but not photorealistic enough for
-- cliffs to be easily recognizable in and of themselves)
--
-- Probably a better way to address this would be to use variations
-- on this file's basic concept, as a means of collecting data that
-- could then be built into a mapping add-on of some kind; ideas:
--
-- - Press a button to toggle line-drawing; then, run along a cliff
-- edge or face; use this to literally outline impassable terrain
--
-- - This'd be great for small fences, which this file's heightmap
-- approach can miss completely (if they're thin enough; think
-- of the fences between Windhelm's guild stores and crafting
-- hall)
--
-- - Press a button to outline walkable areas, and then another
-- button to fill them in (sort of like making navmeshes with
-- N-gon support); overlapping areas could be automatically
-- merged. Could show N-gon boundaries in-world and maybe even
-- have controls to move/delete the last point and so on
--
-- - Splitting N-gons into convex meshes may be a relevant
-- concern, especially if you need to test whether a point
-- or line intersects them (separating axis theorem requires
-- convex shapes)
--
-- - Good way to model walkable areas quickly, with the caveat
-- that you won't have any height data inside of an N-gon e.g.
-- hills unless you allow concentric/nested N-gons
--
-- - Failing that, do the same thing that this file does, but make
-- it so that whatever you're collecting the data for can fill in
-- the gaps implicitly whenever there isn't a large known height
-- difference somewhere in those gaps
--
-- - Potential cool ideas:
--
-- - Map out a zone (per above) and collect the data somehow; pack
-- it into an add-on and make a literal GPS that can show you the
-- fastest path to a destination both on the zone map and in 3D
--
-- - You could also "record" special-case paths, e.g. maneuvers
-- needed to safely slide down cliffs that would ordinarily
-- kill you to death; these could be enabled for routing, with
-- 3D markers displayed at the edge of the cliff (think of a
-- giant "slow down now!" sign) and waypoints displayed on the
-- specific parts a player needs to land on to survive their
-- way down
--
-- - Could measure water, too, and factor that into routes for
-- Argonians (who not only swim between walk and sprint speed,
-- but also retain movement speed buffs while swimming)
--
local function sign(v)
return v >= 0 and 1 or -1
end
local function round(v, mult)
mult = mult or 1
return math.floor((v / mult) + (sign(v) * 0.5)) * mult
end
-- remember: X and Z are lateral in this game; Y is height
-- performance optimizations need testing; all parts are tagged with
-- "UNTESTED OPTIMIZATION"
-- goal is to reduce overhead of redrawing the grid once we have a TON of
-- coordinates stored
--
-- - associate each gridline with the two points it connects, bidirection-
-- ally
--
-- - when rendering the grid, for each pair of points we want to connect,
-- check if they already have a gridline control; if so, just update
-- its position
--
-- - further optimizations may be possible by writing the height of
-- each point to the gridline control, i.e. you'd be able to tell
-- if either point actually changed and skip updating if not
--
-- - after the grid is rendered, loop over all extant controls and
-- kill any that have been severed from their points or that have no
-- points
-- NOTE: i didn't test long-range travel (reportedly UI stuff recenters
-- or something when you move too far) due to other performance concerns
-- that kicked in first, so i don't know if that will cause rendering
-- inaccuracies; i also didn't test transitioning into another zone
-- here's a fun thing to try after covering an area in gridlines:
-- /script SetShouldRenderWorld(false)
--
-- NOTE: rendering too large a grid WILL cause the game to malfunction,
-- eventually being unable to reliably draw new grid pieces even (or
-- especially) after a /reloadui. sadly some compromises will need to
-- be made; ideas:
--
-- - fade gridlines out at a distance; cap the render distance
--
-- - render the grid at a lower resolution past some threshold (i.e.
-- the current 175-unit resolution near, then 350 further out, etc.)
--
-- - combine contiguous gridlines that have identical or nearly-
-- identical slopes (very good for a lot of cities)
--
-- TODO: see if we can count the number of gridlines we can render
-- before the game chokes; measuring this will be complicated by the
-- fact that either a log-out (not sure) or a full game restart is
-- needed to ensure stable behavior (so if you count to 400, /reloadui
-- to see if things break, and they don't, then you need to restart
-- the game and try 600; etc.)
--
-- TODO: save the gathered coordinates; probably best to use a binary
-- format; could compact X- and Z-axis values by dividing them by the
-- Map resolution
local Coord = {}
do
Coord.meta = { __index = Coord }
function Coord:new(x, y, z, lateralDistance, properties)
local result = setmetatable({}, self.meta)
result.x = x
result.y = y
result.z = z
result.gridlines = {}
result.lateralDistance = lateralDistance or 0
--
local props = properties or {}
result.isWater = props.isWater or false
--
return result
end
function Coord:distanceTo(other)
local x = (other.x - self.x)
local y = (other.y - self.y)
local z = (other.z - self.z)
return math.sqrt(x*x + y*y + z*z)
end
function Coord:establishGridline(otherPoint, control)
-- UNTESTED OPTIMIZATION
if otherPoint.x ~= self.x then
local offset = sign(self.x - otherPoint.x)
self.gridlines[offset] = self.gridlines[offset] or {}
self.gridlines[offset][0] = control
else
local offset = sign(self.z - otherPoint.z)
self.gridlines[0] = self.gridlines[0] or {}
self.gridlines[0][offset] = control
end
end
function Coord:getGridlineFor(otherPoint)
-- UNTESTED OPTIMIZATION
if otherPoint.x ~= self.x then
local offset = sign(self.x - otherPoint.x)
self.gridlines[offset] = self.gridlines[offset] or {}
return self.gridlines[offset][0]
else
local offset = sign(self.z - otherPoint.z)
self.gridlines[0] = self.gridlines[0] or {}
return self.gridlines[0][offset]
end
end
function Coord:midpointTo(other)
local x = (other.x + self.x) / 2
local y = (other.y + self.y) / 2
local z = (other.z + self.z) / 2
return x, y, z
end
function Coord:update(y, lateralDistance, properties)
if lateralDistance <= self.lateralDistance and y < self.y then
self.y = y
self.lateralDistance = lateralDistance
--
local props = properties or {}
self.isWater = props.isWater or false
end
end
end
local Plane = {}
do
Plane.meta = { __index = Plane }
function Plane:new(ownerMap)
assert(ownerMap ~= nil)
local result = setmetatable({}, self.meta)
result.owner = ownerMap
result.coords = {}
return result
end
function Plane:forEachCoord(functor)
for x, list in pairs(self.coords) do
for z, point in pairs(list) do
if functor(point, self) then
return
end
end
end
end
function Plane:getHeightAt(x, z)
local point = self.coords[x]
if point then
point = point[z]
if point then
return point.y
end
end
end
function Plane:getCoord(x, z)
local list = self.coords[x]
if list then
return list[z]
end
end
function Plane:setHeightAt(x, y, z, props)
local lateralDistance = 0
do -- round coordinates to resolution
local ld = 0
local roundX = round(x, self.owner.resolution)
local roundZ = round(z, self.owner.resolution)
local dX = roundX - x
local dZ = roundZ - z
lateralDistance = math.sqrt(dX * dX + dZ * dZ)
x = roundX
y = round(y)
z = roundZ
end
local a = self.coords[x] or {}
local b = a[z]
if b then
b:update(y, lateralDistance, props)
else
a[z] = Coord:new(x, y, z, lateralDistance, props)
end
self.coords[x] = a
end
end
local Map = {}
do
Map.meta = { __index = Map }
function Map:new(zoneId, resolution)
local result = setmetatable({}, self.meta)
result.zoneId = zoneId
result.name = GetZoneNameById(zoneId)
--
result.planes = {}
result.resolution = resolution or 175
result.tolerance = 20
return result
end
function Map:forEachCoord(planeIndex, functor)
local plane = self:getOrCreatePlane(planeIndex)
plane:forEachCoord(functor)
end
function Map:getOrCreatePlane(plane)
plane = plane or 1
if not self.planes[plane] then
self.planes[plane] = Plane:new(self)
end
return self.planes[plane]
end
function Map:getHeightAt(planeIndex, x, y)
local plane = self:getOrCreatePlane(planeIndex)
return plane:getHeightAt(x, y)
end
function Map:setHeightAt(planeIndex, x, y, z, props)
local plane = self:getOrCreatePlane(planeIndex)
return plane:setHeightAt(x, y, z, props)
end
end
local MapDatabase = {}
do
MapDatabase.meta = { __index = MapDatabase }
function MapDatabase:new()
local result = setmetatable({}, self.meta)
result.zones = {}
return result
end
function MapDatabase:getOrCreateZone(id)
local map = self.zones[id]
if not map then
map = Map:new(id)
self.zones[id] = map
end
return map
end
function MapDatabase:getZone(id)
return self.zones[id] or nil
end
end
local MAP_DATABASE = MapDatabase:new()
CobbMapTestMapDB = MAP_DATABASE
local currentPlane = 1
local ui = {
gridlinePool = nil,
}
local function _clearRenderedGrid()
for i = 1, CobbMapHelperGridlines:GetNumChildren() do
local control = CobbMapHelperGridlines:GetChild(i)
control:SetHidden(true)
control:Destroy3DRenderSpace()
end
end
local function _renderGrid()
local THICKNESS = 0.1
--
local zID, x, y, z = GetUnitWorldPosition("player")
local map = MAP_DATABASE:getZone(zID)
if not map then
d("No map exists for this zone.")
return
end
--
local oldCount = CobbMapHelperGridlines:GetNumChildren()
local renderCount = 0
local resolution = map.resolution
--d("drawing a grid at resolution " .. tostring(resolution))
map:forEachCoord(currentPlane, function(point, plane)
local x = point.x
local z = point.z
--
local function _getControl()
renderCount = renderCount + 1
local control
if renderCount <= oldCount then
control = CobbMapHelperGridlines:GetChild(renderCount)
else
local key
control, key = ui.gridlinePool:AcquireObject()
end
return control
end
local function _drawGridline(a, b) -- args are Coord structs
local function _normalizeDistance(d)
local x0 = WorldPositionToGuiRender3DPosition(0, 0, 0)
local nd = WorldPositionToGuiRender3DPosition(d, 0, 0)
return nd - x0
end
--
-- UNTESTED OPTIMIZATION
local control = a:getGridlineFor(b) or b:getGridlineFor(a)
if not control then
control = _getControl()
end
control.point1 = a
control.point2 = b
a:establishGridline(b, control)
b:establishGridline(a, control)
--
if not control:Has3DRenderSpace() then
control:Create3DRenderSpace()
control:Set3DRenderSpaceUsesDepthBuffer(false)
end
local xm, ym, zm = a:midpointTo(b)
xm, ym, zm = WorldPositionToGuiRender3DPosition(xm, ym, zm)
local distance = _normalizeDistance(a:distanceTo(b))
control:Set3DRenderSpaceOrigin(xm, ym, zm)
control:Set3DLocalDimensions(distance + THICKNESS, THICKNESS)
do -- rotation
local yaw = 0
local pitch = 0
local roll = 0
local height = b.y - a.y
local slope = math.atan2(height, resolution)
if a.x == b.x then
pitch = 0
yaw = math.rad(90) + slope
roll = math.rad(90)
control:SetColor(0.25, 0.80, 0.10, 1)
else
pitch = math.rad(90)
yaw = 0
roll = slope
control:SetColor(1, 0, 0, 1)
end
control:Set3DRenderSpaceOrientation(pitch, yaw, roll)
end
--
control:SetHidden(false)
end
local function _drawPoint(point)
--d("rendering orphan point (child " .. renderCount .. "...")
local control = _getControl()
control.point1 = point
control.point2 = point
if not control:Has3DRenderSpace() then
control:Create3DRenderSpace()
control:Set3DRenderSpaceUsesDepthBuffer(false)
end
local ux, uy, uz = WorldPositionToGuiRender3DPosition(point.x, point.y, point.z)
control:Set3DRenderSpaceOrigin(ux, uy, uz)
control:Set3DLocalDimensions(THICKNESS, THICKNESS)
control:SetColor(1, 0, 0, 1)
--
control:SetHidden(false)
end
--
local function _renderAdjacent(xOffset, zOffset)
local otherX = x + (resolution * xOffset)
local otherZ = z + (resolution * zOffset)
local other = plane:getCoord(otherX, otherZ)
if not other then
return false
end
--d(LocalizeString("(<<1>>, <<2>>) rendering connection to (<<3>>, <<4>>) given offsets (<<5>>, <<6>>)", x, z, otherX, otherZ, xOffset, zOffset))
_drawGridline(point, other)
return true
end
local hasAdjacent = false
hasAdjacent = _renderAdjacent(1, 0) or hasAdjacent
hasAdjacent = _renderAdjacent(0, 1) or hasAdjacent
do
local west = plane:getCoord(x + resolution, z)
local north = plane:getCoord(x, -resolution + z)
hasAdjacent = hasAdjacent or (west or north)
end
--d(LocalizeString("rendered connections for (<<1>>, <<2>>)", x, z))
if not hasAdjacent then
_drawPoint(point)
end
end)
for i = 1, CobbMapHelperGridlines:GetNumChildren() do
-- UNTESTED OPTIMIZATION
local control = CobbMapHelperGridlines:GetChild(i)
local a = control.point1
local b = control.point2
if a and b then
local established = a:getGridlineFor(b)
if established ~= control then
control.point1 = nil
control.point2 = nil
control:SetHidden(true)
control:Destroy3DRenderSpace()
end
else
control.point1 = nil
control.point2 = nil
control:SetHidden(true)
control:Destroy3DRenderSpace()
end
end
end
local function Init()
do
ui.gridlinePool = ZO_ControlPool:New("CobbMapHelperGridlineTmpl", CobbMapHelperGridlines, "CobbMapHelperGridlineTmpl")
end
CobbMapHelperGridlines:Create3DRenderSpace()
local fragment = ZO_SimpleSceneFragment:New(CobbMapHelperGridlines)
HUD_UI_SCENE:AddFragment(fragment)
HUD_SCENE:AddFragment(fragment)
LOOT_SCENE:AddFragment(fragment)
Lib3D:RegisterWorldChangeCallback("CobbMapHelperOnWorldChange", function(identifier, zoneIndex, isValidZone, newZone)
_clearRenderedGrid()
local wx, wy, wz = WorldPositionToGuiRender3DPosition(0, 0, 0)
--
-- Lib3D says you need to use the "GUI render 3D position" as a basis,
-- but I only saw correct behavior by avoiding that??
--
-- I use WorldPositionToGuiRender3DPosition for each individual gridline
-- and maybe that's incorrect behavior i.e. I may be doing there what I
-- should be doing here. I should test that later
--
wx = 0
wy = 0
wz = 0
CobbMapHelperGridlines:Set3DRenderSpaceOrigin(wx, wy, wz)
end)
SLASH_COMMANDS["/cobbmapgrid"] = function()
_renderGrid()
end
--
local function _poller()
if not IsPlayerMoving() then
return
end
local zID, x, y, z = GetUnitWorldPosition("player")
if zID and x then
local map = MAP_DATABASE:getOrCreateZone(zID)
local props = {
isWater = IsUnitSwimming("player"),
}
map:setHeightAt(currentPlane, x, y, z, props)
end
end
EVENT_MANAGER:RegisterForUpdate("CobbMapHelperPositionPoll", 100, _poller)
EVENT_MANAGER:RegisterForEvent("CobbMapHelperMountListener", EVENT_MOUNTED_STATE_CHANGED, function(eventCode, mounted)
if mounted then
EVENT_MANAGER:RegisterForUpdate("CobbMapHelperPositionPoll", 20, _poller)
else
EVENT_MANAGER:RegisterForUpdate("CobbMapHelperPositionPoll", 100, _poller)
end
end)
EVENT_MANAGER:RegisterForUpdate("CobbMapHelperRedraw", 200, function()
if not IsPlayerMoving() then
return
end
_renderGrid()
end)
end
EVENT_MANAGER:RegisterForEvent("CobbMapHelperOnLoad", EVENT_ADD_ON_LOADED, function(eventCode, addonName)
if addonName ~= "CobbMapHelper" then
return
end
Init()
end)
CobbMapHelper = {}
function CobbMapHelper.renderTest(pitch, yaw, roll)
if not CobbMapHelperRenderTest:Has3DRenderSpace() then
CobbMapHelperRenderTest:Create3DRenderSpace()
CobbMapHelperRenderTest:Set3DRenderSpaceUsesDepthBuffer(false)
end
do
local wx, wy, wz = WorldPositionToGuiRender3DPosition(0, 0, 0)
wx = 0
wy = 0
wz = 0
CobbMapHelperRenderTest:Set3DRenderSpaceOrigin(wx, wy, wz)
end
local tex = CobbMapHelperRenderTest:GetChild(1)
if not tex:Has3DRenderSpace() then
tex:Create3DRenderSpace()
tex:Set3DRenderSpaceUsesDepthBuffer(false)
end
local zID, x, y, z = GetUnitWorldPosition("player")
local ux, uy, uz = WorldPositionToGuiRender3DPosition(x, y, z)
tex:Set3DRenderSpaceOrigin(ux, uy, uz)
tex:Set3DLocalDimensions(3, 1)
tex:Set3DRenderSpaceOrientation(math.rad(pitch), math.rad(yaw), math.rad(roll))
CobbMapHelperRenderTest:SetHidden(false)
end
<GuiXml>
<Controls>
<Texture name="CobbMapHelperGridlineTmpl" virtual="true"
color="FF0000FF"
width="1"
height="1"
>
<Controls>
</Controls>
</Texture>
<TopLevelControl name="CobbMapHelperGridlines">
<Controls>
</Controls>
</TopLevelControl>
<TopLevelControl name="CobbMapHelperRenderTest" hidden="true">
<Controls>
<Texture name="CobbMapHelperGridlineTmpl"
color="FF0080FF"
width="1"
height="1"
/>
</Controls>
</TopLevelControl>
</Controls>
</GuiXml>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment