I’d like to have a UILabel stay on top of a node in ARKit, similar to the dimensions labels in iOS 12’s Measure app.
I’ve tried adding the label as a plane node and using a billboard constraint, but then the text gets smaller as you move away, which isn’t ideal.
At WWDC, they referred to this as Screen Space, but didn’t say how to achieve it.
Thanks in advance!
I've come up with the following solution to make it work.
First, I create the UILabel and add it as a subview. Next, I convert the position of the node I want to follow to screen coordinates in renderer(_: updateAtTime:). Now the label follows the node correctly and stays fixed in scale. However, the label stays horizontal to the screen, which looks weird. To make it stay horizontal to the world, I rotate the label according to the ARCamera's yaw (z rotation).
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
// Convert the node's position to screen coordinates
let screenCoordinate = self.sceneView.projectPoint(node.position)
DispatchQueue.main.async {
// Move the label
label.center = CGPoint(x: CGFloat(screenCoordinate.x), y: CGFloat(screenCoordinate.y))
// Hide the label if the node is "behind the screen"
label.isHidden = (screenCoordinate.z > 1)
// Rotate the label
if let rotation = sceneView.session.currentFrame?.camera.eulerAngles.z {
label.transform = CGAffineTransform(rotationAngle: CGFloat(rotation + Float.pi/2))
}
}
}
Try with a SCNText geometry, I've used the following code some months ago:
var txtNote = SCNText()
txtNote.string = "Hello AR word!"
txtNote.font = UIFont.systemFont(ofSize: 2)
txtNote.extrusionDepth = 1.0
txtNote.containerFrame = CGRect(x: 2.5, y: 0, width: 16, height: 10)
txtNote.isWrapped = true
txtNote.alignmentMode = kCAAlignmentLeft
txtNote.materials.first?.diffuse.contents = UIColor.black.cgColor
var txtNode = SCNNode(geometry: txtNote)
Related
I have a label in GameScene, there is no auto layout to use here, how I can use CGpoint to make sure this label stay at the same position on different screens.
Below is the code where I put my score label
scoreLabel?.position = CGPoint(x: -size.width / 2 + 120, y: size.height / 2 - 150)
let scoreLabel = //insert functionality of scoreLabel here
scoreLabel?.position = CGPoint(x: size.width + //insert dimention,
y: size.height + //insert dimension)
self.addChild(scoreLabel)
seeing your previous comment, I would suggest you use the following functions, which change the dimensions when they are run on different screen sizes:
AspectFill
AspectFit
ResizeFill
Here is a link that could help you on how to use these functions: Sprite Kit Scene Editor GameScene.sks scene width and height
Hope this helps!
Here is what I put in my View Controller:
var h, w = 1000
class GameViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
if let view = self.view as! SKView? {
w = view.frame.width / (view.frame.height / 1000)
let scene = GameScene(size: CGSize(width: w, height: h))
scene.scaleMode = .aspectFill
view.presentScene(scene)
}
}
}
It's handy to know that I make the height of any device 1000. It's much easier to make it universal.
Then I place everything percentage-wise on the screen.
label.position.x = w/2
label.position.y = h/2
label2.position.x = w/2
label2.position.y = h/2 - 50
These lines of code will place 2 labels in the center of the screen. Even if you rotate your device, and even if you use different sized devices. That - 50 I used will always stay the same.
My main takeaway: I create global states of the relative size of the screen.
Helpful tip: I always make my scene's anchor point to be .zero. That way, the min width and height are always 0, and the max height is always 1000.
I'm programmatically generating a set of UILabels, attaching them to SCNNodes and then placing them in a scene.
The problem is that the text on some of the labels doesn't appear. This occurs (seemingly) randomly.
Here's the code:
var labels = [SCNNode]()
var index: Int
var x: Float
var y: Float
let N = 3
for i in 0 ... N-1 {
for k in 0 ... N-1 {
let node = label()
labels.append(node)
index = labels.count - 1
x = Float(i) * 0.5 - 0.5
y = Float(k) * 0.5 - 0.5
sceneView.scene.rootNode.addChildNode(labels[index])
labels[index].position = SCNVector3Make(x, y, -1)
}
}
and the method to create the label node:
func label() -> SCNNode {
let node = SCNNode()
let label = UILabel(frame: CGRect(x: CGFloat(0), y: CGFloat(0),
width: CGFloat(100), height: CGFloat(50)))
let plane = SCNPlane(width: 0.2, height: 0.1)
label.text = "test"
label.adjustsFontSizeToFitWidth = true
plane.firstMaterial?.diffuse.contents = label
node.geometry = plane
return node
}
The labels themselves always appear correctly, it's just that some of them are blank, with no text.
I've tried playing around with the size of the label, the size of the plane it is attached to, the font size etc - nothing seems to work.
I've also tried enclosing the label creation in DispatchQueue.main.async { ... }, which didn't help either.
I'm moderately new to Swift and iOS, so could easily have missed something very obvious.
Thanks in advance!
EDIT:
(1) Setting label.backgroundColor = UIColor.magenta makes it clear that in fact the label is not being created, but the node / plane is.
Some of the labels are left white (i.e only the SCNNode is being rendered), however after a short delay they sometimes then become magenta and the text will appear. Some of the labels will remain missing though.
(2) It further appears that it's related to the position and orientation of the node (label) relative to the camera. I created a large (10x10) grid of labels, then tested placing the camera at different initial positions in the grid. The likelihood that a node appeared seemed directly related to the distance of the node from the initial camera position. Those nodes directly in front of the camera were always rendered, and those far away almost never were.
(3) workaround / hack is to convert the labels to images, and use them instead - code is at https://github.com/Jordan-Campbell/uiimage-arkit-testing if anyone is interested.
If you are labeling things in AR, 99% of the time it is better to do so in "Screen Space" rather than in "Perspective".
Benefits of labels in Screen Space:
ALWAYS readable, regardless of user's distance from the label
You can use regular UILabels, no need to draw them to an image and then map the image to an SCNPlane.
Your app will have a first party feel to it because Apple uses Screen Space for their labels in all of their AR apps (see Measure).
You will be able to use standard animations on your UILabel, animations are much more complex to set up when working with content in Perspective.
If you are sold on Screen Space, let me know and I'll be happy to put up some code showing you the basics.
Use main thread for creating and adding labels to scene. This will make things faster, and avoid coupling this addition to scene with plane detection this really slows down the rendering. this works at times.
DispatchQueue.main.async {
}
Use a UIView SuperView as Parent to your label this would make things smoother.
I'm having a hard time setting boundaries and positioning camera properly inside my view after panning. So here's my scenario.
I have a node that is bigger than the screen and I want to let user pan around to see the full map. My node is 1000 by 1400 when the view is 640 by 1136. Sprites inside the map node have the default anchor point.
Then I've added a camera to the map node and set it's position to (0.5, 0.5).
Now I'm wondering if I should be changing the position of the camera or the map node when the user pans the screen ? The first approach seems to be problematic, since I can't simply add translation to the camera position because position is defined as (0.5, 0.5) and translation values are way bigger than that. So I tried multiplying/dividing it by the screen size but that doesn't seem to work. Is the second approach better ?
var map = Map(size: CGSize(width: 1000, height: 1400))
override func didMove(to view: SKView) {
(...)
let pan = UIPanGestureRecognizer(target: self, action: #selector(panned(sender:)))
view.addGestureRecognizer(pan)
self.anchorPoint = CGPoint.zero
self.cam = SKCameraNode()
self.cam.name = "camera"
self.camera = cam
self.addChild(map)
self.map.addChild(self.cam!)
cam.position = CGPoint(x: 0.5, y: 0.5)
}
var previousTranslateX:CGFloat = 0.0
func panned (sender:UIPanGestureRecognizer) {
let currentTranslateX = sender.translation(in: view!).x
//calculate translation since last measurement
let translateX = currentTranslateX - previousTranslateX
let xMargin = (map.nodeSize.width - self.frame.width)/2
var newCamPosition = CGPoint(x: cam.position.x, y: cam.position.y)
let newPositionX = cam.position.x*self.frame.width + translateX
// since the camera x is 320, our limits are 140 and 460 ?
if newPositionX > self.frame.width/2 - xMargin && newPositionX < self.frame.width - xMargin {
newCamPosition.x = newPositionX/self.frame.width
}
centerCameraOnPoint(point: newCamPosition)
//(re-)set previous measurement
if sender.state == .ended {
previousTranslateX = 0
} else {
previousTranslateX = currentTranslateX
}
}
func centerCameraOnPoint(point: CGPoint) {
if cam != nil {
cam.position = point
}
}
Your camera is actually at a pixel point 0.5 points to the right of the centre, and 0.5 points up from the centre. At (0, 0) your camera is dead centre of the screen.
I think the mistake you've made is a conceptual one, thinking that anchor point of the scene (0.5, 0.5) is the same as the centre coordinates of the scene.
If you're working in pixels, which it seems you are, then a camera position of (500, 700) will be at the top right of your map, ( -500, -700 ) will be at the bottom left.
This assumes you're using the midpoint anchor that comes default with the Xcode SpriteKit template.
Which means the answer to your question is: Literally move the camera as you please, around your map, since you'll now be confident in the knowledge it's pixel literal.
With one caveat...
a lot of games use constraints to stop the camera somewhat before it gets to the edge of a map so that the map isn't half off and half on the screen. In this way the map's edge is showing, but the furthest the camera travels is only enough to reveal that edge of the map. This becomes a constraints based effort when you have a player/character that can walk/move to the edge, but the camera doesn't go all the way out there.
I want to create a ring with a 3D effect using Sprite Kit. (SEE IMAGES)
I tried subclassing a SKNode and adding two nodes as children. (SEE CODE)
One node was a complete SKShapeNode ellipse, and the other was half ellipse using SKCropNode with a higher zPosition.
It looks good, but the SKCropNode increases the app CPU usage from 40% to 99%.
Any ideas on how to reduce the SKCropNode performance cost, or any alternative to create the same ring 3D effect?
class RingNode: SKNode {
let size: CGSize
init(size: CGSize, color: SKColor)
{
self.size = size
self.color = color
super.init()
ringPartsSetup()
}
private func ringPartsSetup() {
// LEFT PART (half ellipse)
let ellipseNodeLeft = getEllipseNode()
let leftMask = SKSpriteNode(texture: nil, color: SKColor.blackColor(), size: CGSize(
width: ellipseNodeLeft.frame.size.width/2,
height: ellipseNodeLeft.frame.size.height))
leftMask.anchorPoint = CGPoint(x: 0, y: 0.5)
leftMask.position = CGPoint(x: -ellipseNodeLeft.frame.size.width/2, y: 0)
let leftNode = SKCropNode()
leftNode.addChild(ellipseNodeLeft)
leftNode.maskNode = leftMask
leftNode.zPosition = 10 // Higher zPosition for 3D effect
leftNode.position = CGPoint(x: -leftNode.frame.size.width/4, y: 0)
addChild(leftNode)
// RIGHT PART (complete ellipse)
let rightNode = getEllipseNode()
rightNode.position = CGPoint(x: 0, y: 0)
rightNode.zPosition = 5
addChild(rightNode)
}
private func getEllipseNode() -> SKShapeNode {
let ellipseNode = SKShapeNode(ellipseOfSize: CGSize(
width: size.width,
height: size.height))
ellipseNode.strokeColor = SKColor.blackColor()
ellipseNode.lineWidth = 5
return ellipseNode
}
}
You've got the right idea with your two-layer approach and the half-slips on top. But instead of using a shape node inside a crop node, why not just use a shape node whose path is a half-ellipse? Create one using either CGPath or UIBezierPath API — use a circular arc with a transform to make it elliptical — then create your SKShapeNode from that path.
You may try converting your SKShapeNode to an SKSpriteNode. You can use SKView textureFromNode: (but we aware of issues with scale that require you to use it only after the node has been added to the view and at least one update cycle has run), or from scratch using an image (created programatically with a CGBitmapContext, of course).
I'm learning SpriteKit and Swift, and I'm trying to make 5 small images "spawn" at the top of the screen, and slide down to the bottom. This is what I got so far, added in the didMoveToView method.
var myArray = NSMutableArray()
myArray.addObject(NSNumber(int: 40))
myArray.addObject(NSNumber(int: 80))
myArray.addObject(NSNumber(int: 120))
myArray.addObject(NSNumber(int: 160))
myArray.addObject(NSNumber(int: 200))
for item in myArray
{
let location = CGPoint(x: CGFloat(item.floatValue), y: 1)
let sprite = SKSpriteNode(imageNamed: "myImage")
sprite.xScale = 0.5
sprite.yScale = 0.5
sprite.position = location
let action = SKAction.moveToY(-4, duration: 4.5)
sprite.runAction(action)
addChild(sprite)
}
But all the objects appear at the bottom. I've tried to change the y-position, with the same result..
Thanks!
You need to increase the y coordinate.
Think about the axis system as the left bottom corner is (0,0) and the top right is (self.frame.size.width,self.frame.size.height)
You may also want to read about anchor points - the default anchor point for an object is 0.5,0.5 so if you locate it on 1 half of it (+ 1 pixel) will appear from the top.
If you want it to pop in from out of the screen you need either to change the anchor point or the poisition
Try changing your location variable to this:
let location = CGPoint(x: CGFloat(item.floatValue) , y:self.size.height)
This will set the y-value to the top of the screen and move the nodes there.